diff --git a/apps/web/src/components/agent-sessions/session-detail/payload-view.tsx b/apps/web/src/components/agent-sessions/session-detail/payload-view.tsx index 4c7dc3898..96dca07cd 100644 --- a/apps/web/src/components/agent-sessions/session-detail/payload-view.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/payload-view.tsx @@ -7,7 +7,7 @@ import { highlightCode } from "@/lib/sugar-high" /** * The rendered ↔ raw affordances every captured body shares, wherever it is - * opened — a transcript block, the Traces expansion, the Flow drawer. Markdown + * opened — a transcript block, the span popover. Markdown * layout and pretty-printed JSON are readings of the capture, and a reading can * hide things — whitespace, key order, a literal `**` — so every rendered body * keeps a way back to the captured bytes. diff --git a/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx b/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx index 507e0227b..ec6285a97 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx @@ -4,6 +4,9 @@ // two layout globals below are stubbed for that reason alone. Nothing here // navigates: span clicks raise `onSelectSpan` for the page to handle, and the // trace links render through a mocked `Link` so no router needs mounting. +// +// The span inspection overlay portals to `document.body`, so it is read through +// `spanPopover()` below rather than the render's own container. import { useState, type ReactNode } from "react" import { cleanup, fireEvent, render, screen, within } from "@testing-library/react" @@ -270,6 +273,22 @@ const { turns: delegationTurns, summary: delegationSummary } = sessionOf([ const EMPTY = new Set() const noop = () => {} +/** The one span-inspection overlay, wherever it was opened from. */ +function spanPopover(): HTMLElement { + const popup = document.querySelector('[data-slot="span-popover"]') + if (popup === null) throw new Error("no span popover is open") + return popup +} + +/** The dimmed page behind the overlay — the scrim that gives it its depth. */ +function spanScrim(): HTMLElement | null { + return document.querySelector('[data-slot="dialog-backdrop"]') +} + +function spanPopoverCount(): number { + return document.querySelectorAll('[data-slot="span-popover"]').length +} + /** The waterfall's expansion state lives in SessionViews, so the tests supply it. */ function Waterfall(props: { turns?: readonly SessionTurn[] @@ -350,16 +369,28 @@ describe("SessionOverview", () => { }), ]) + /** `?span=` is a search param on the real page; here it is local state. */ function Overview(props: { turns?: readonly SessionTurn[] summary?: SessionSummary - onOpenSpan?: (spanId: string) => void + /** What a pasted `?span=` link lands with. */ + initialSpanId?: string + onSelectSpan?: (spanId: string | undefined) => void + onOpenTraceView?: () => void }) { + const [selectedSpanId, setSelectedSpanId] = useState(props.initialSpanId) return ( { + setSelectedSpanId(spanId) + props.onSelectSpan?.(spanId) + }} + spanTab={undefined} + onSpanTabChange={noop} + onOpenTraceView={props.onOpenTraceView ?? noop} /> ) } @@ -373,28 +404,69 @@ describe("SessionOverview", () => { }) // The five-second answer: the verdict names what killed the final turn and - // links the span that is its evidence — the one link the v2 page lost. + // opens the span that is its evidence — the one link the v2 page lost. it("says a failed session failed, names the cause, and opens the failing span", () => { - const onOpenSpan = vi.fn() - render() + const onSelectSpan = vi.fn() + render() expect(screen.getByText("Failed")).toBeTruthy() expect(screen.getAllByText("context_length_exceeded").length).toBeGreaterThan(0) fireEvent.click(screen.getByRole("button", { name: /Open failing span/ })) // The deepest span carrying the failure, not the wrapper that copied it. - expect(onOpenSpan).toHaveBeenCalledWith("f-llm") + expect(onSelectSpan).toHaveBeenCalledWith("f-llm") + // In place, against the button that named it — the Overview is still on + // screen behind the panel. + expect(within(spanPopover()).getByText("f-llm")).toBeTruthy() }) // A mid-session failure the session recovered from is not a failed session — // but it is exactly what the findings list exists to surface. - it("completes-with-findings when something failed mid-session, and links it", () => { - const onOpenSpan = vi.fn() - render() + it("completes-with-findings when something failed mid-session, and opens it", () => { + const onSelectSpan = vi.fn() + render() expect(screen.getByText(/Completed, with 1 finding/)).toBeTruthy() fireEvent.click(screen.getByText("error · run_tests")) - expect(onOpenSpan).toHaveBeenCalledWith("tool-3") + expect(onSelectSpan).toHaveBeenCalledWith("tool-3") + }) + + // The finding's evidence used to live one view away: clicking it swapped the + // page out from under the reader. It opens over this page instead, and the + // way across is inside the panel for the reader who wants the whole waterfall. + it("inspects a finding's span in place, and still offers the way across", () => { + const onOpenTraceView = vi.fn() + render() + + fireEvent.click(screen.getByText("error · run_tests")) + const popover = within(spanPopover()) + expect(popover.getByText("exit 1")).toBeTruthy() + // A scrim behind it, so the page reads as underneath rather than beside. + expect(spanScrim()).not.toBeNull() + expect(onOpenTraceView).not.toHaveBeenCalled() + + fireEvent.click(popover.getByRole("button", { name: "Open in Traces view" })) + expect(onOpenTraceView).toHaveBeenCalled() + }) + + // The panel is no longer anchored to the element that named the span, so a + // selection this view never made — a pasted `?span=` link, or the reader + // arriving from another view — opens it here too. + it("opens a pasted span link without a click, and Escape clears it", () => { + const onSelectSpan = vi.fn() + render( + , + ) + + expect(within(spanPopover()).getAllByText(/claude-opus-5/).length).toBeGreaterThan(0) + + fireEvent.keyDown(spanPopover(), { key: "Escape" }) + expect(onSelectSpan).toHaveBeenCalledWith(undefined) }) it("says a clean session completed cleanly, and what that claim covers", () => { @@ -406,14 +478,14 @@ describe("SessionOverview", () => { }) // The shape strip replaces the turn digest: one cell per turn, colored by - // what the findings attribute to it, each a door into the Traces view. + // what the findings attribute to it, each opening the turn's anchor span. it("draws one cell per turn and opens the turn's anchor from a click", () => { - const onOpenSpan = vi.fn() - render() + const onSelectSpan = vi.fn() + render() const cellTwo = screen.getByRole("button", { name: "2" }) fireEvent.click(cellTwo) - expect(onOpenSpan).toHaveBeenCalledWith("agent-2") + expect(onSelectSpan).toHaveBeenCalledWith("agent-2") }) it("says no cost was reported rather than pricing tokens itself", () => { @@ -501,15 +573,15 @@ describe("SessionWaterfall", () => { expect(screen.queryByText(/^idle \d/)).toBeNull() }) - it("selects a span for inline expansion from a click, and collapses on the second", () => { + it("opens a span's panel from a click, and closes it on the second", () => { const onSelectSpan = vi.fn() const view = render() fireEvent.click(screen.getByText("grep_repo")) expect(onSelectSpan).toHaveBeenCalledWith("tool-2") - // Clicking the already-expanded row collapses it. The expansion repeats - // the tool's name in its payload card, so the row is the first match. + // Clicking the open row closes it. The panel repeats the tool's name in + // its payload card, so the row is the first match. view.rerender() fireEvent.click(screen.getAllByText("grep_repo")[0]!) expect(onSelectSpan).toHaveBeenLastCalledWith(undefined) @@ -522,25 +594,24 @@ describe("SessionWaterfall", () => { expect(link.getAttribute("href")).toBe("/traces/trace-1") }) - it("expands the selected span inline, directly under its row", () => { - const view = render() + it("opens the selected span over the list, leaving its row marked underneath", () => { + render() const row = screen.getAllByText(/^chat$/)[0]!.closest("button")! expect(row.getAttribute("aria-current")).toBe("true") - // The expansion carries the captured messages at full width — the user's + // The panel carries the captured messages at full width — the user's // prompt appears complete here, beyond the truncated turn label above it. - const detail = view.container.querySelector('[data-slot="span-inline-detail"]')! - expect(detail).toBeTruthy() - expect(within(detail as HTMLElement).getByText("fix the webhook retry backoff")).toBeTruthy() - expect(within(detail as HTMLElement).getByRole("button", { name: /Messages/ })).toBeTruthy() - expect(within(detail as HTMLElement).getByText("Open in Traces")).toBeTruthy() + const detail = within(spanPopover()) + expect(detail.getByText("fix the webhook retry backoff")).toBeTruthy() + expect(detail.getByRole("button", { name: /Messages/ })).toBeTruthy() + expect(detail.getByText("Open in Traces")).toBeTruthy() }) - it("leads the expansion's tabs with Details; Attributes and Timing are folded into it", () => { - const view = render() + it("leads the panel's tabs with Details; Attributes and Timing are folded into it", () => { + render() - const detail = within(view.container.querySelector('[data-slot="span-inline-detail"]') as HTMLElement) + const detail = within(spanPopover()) const labels = detail .getAllByRole("button") .filter((button) => button.hasAttribute("aria-pressed")) @@ -570,9 +641,9 @@ describe("SessionWaterfall", () => { }, }), ]) - const view = render() + render() - const detail = view.container.querySelector('[data-slot="span-inline-detail"]') as HTMLElement + const detail = spanPopover() // A failed span opens on Details by default. expect(within(detail).getByRole("button", { name: "Details" }).getAttribute("aria-pressed")).toBe( "true", @@ -584,9 +655,9 @@ describe("SessionWaterfall", () => { }) it("opens an errored span on Details, where the error and the ids are", () => { - const view = render() + render() - const detail = within(view.container.querySelector('[data-slot="span-inline-detail"]') as HTMLElement) + const detail = within(spanPopover()) expect(detail.getByRole("button", { name: "Details" }).getAttribute("aria-pressed")).toBe("true") // The error banner and the identity rows render ahead of the lazily // loaded attribute maps (held at Initial by the atom mock above). @@ -594,15 +665,17 @@ describe("SessionWaterfall", () => { expect(detail.getByText("Trace ID")).toBeTruthy() }) - it("expands one span at a time — the selection, not a set", () => { + it("opens one span at a time — the selection, not a set", () => { const view = render() - expect(view.container.querySelectorAll('[data-slot="span-inline-detail"]')).toHaveLength(1) + expect(spanPopoverCount()).toBe(1) view.rerender() - expect(view.container.querySelectorAll('[data-slot="span-inline-detail"]')).toHaveLength(1) + expect(spanPopoverCount()).toBe(1) + // The one panel followed the selection rather than joining the first. + expect(within(spanPopover()).getAllByText("grep_repo").length).toBeGreaterThan(0) }) - // A call and its result are one event: the expansion shows them as one card + // A call and its result are one event: the panel shows them as one card // whose selector flips between the halves, instead of two stacked cards the // reader has to pair by eye. it("groups a tool call and its result into one card behind a selector", () => { @@ -621,12 +694,10 @@ describe("SessionWaterfall", () => { }, }), ]) - const view = render( - , - ) + render() // Arguments first, pretty-printed; the result is a click away, not a scroll. - const detail = view.container.querySelector('[data-slot="span-inline-detail"]') as HTMLElement + const detail = spanPopover() expect(detail.textContent).toContain('"sql"') expect(detail.textContent).not.toContain('"rows"') @@ -635,18 +706,21 @@ describe("SessionWaterfall", () => { expect(detail.textContent).not.toContain('"sql"') }) - it("moves the span cursor with the arrows, expands on Enter, collapses on Esc", () => { + // The panel is a dialog, so the page-level keys stand down while it is open: + // the arrows walk the list up to the point one opens, and the panel's own + // close (its button, or Escape inside it) is what puts the reader back. + it("moves the span cursor with the arrows, opens on Enter, closes from the panel", () => { const onSelectSpan = vi.fn() const view = render() // First ↓ lands on the first span row — turn 1's root agent span; Enter - // expands it. + // opens it. fireEvent.keyDown(document.body, { key: "ArrowDown" }) fireEvent.keyDown(document.body, { key: "Enter" }) expect(onSelectSpan).toHaveBeenCalledWith("agent-1") view.rerender() - fireEvent.keyDown(document.body, { key: "Escape" }) + fireEvent.click(within(spanPopover()).getByRole("button", { name: "Close span detail" })) expect(onSelectSpan).toHaveBeenLastCalledWith(undefined) }) @@ -798,7 +872,7 @@ describe("SessionFlow", () => { expect(screen.getByText("grep_repo")).toBeTruthy() }) - it("selects a span for the docked drawer from a node click", () => { + it("opens a span's panel from a node click", () => { const onSelectSpan = vi.fn() render() @@ -806,25 +880,23 @@ describe("SessionFlow", () => { expect(onSelectSpan).toHaveBeenCalledWith("tool-2") }) - it("docks a full-width drawer under the canvas for the selected span", () => { - const view = render() + it("opens the selected span's panel, naming the node's turn", () => { + render() - const drawer = view.container.querySelector('[data-slot="span-drawer"]')! - expect(drawer).toBeTruthy() - // The drawer names the span and where it lives, and offers the way across. - expect(within(drawer as HTMLElement).getAllByText(/grep_repo/).length).toBeGreaterThan(0) - expect(within(drawer as HTMLElement).getByText(/Turn 1/)).toBeTruthy() - expect(within(drawer as HTMLElement).getByText("Open in Traces view")).toBeTruthy() + // The panel names the span and where it lives, and offers the way across. + const panel = within(spanPopover()) + expect(panel.getAllByText(/grep_repo/).length).toBeGreaterThan(0) + expect(panel.getByText(/Turn 1/)).toBeTruthy() + expect(panel.getByText("Open in Traces view")).toBeTruthy() }) - it("opens the drawer even for a span the flow drew no node for", () => { + it("opens the panel even for a span the flow drew no node for", () => { // The app's own HTTP span earns no node, but selection addresses spans the - // same way in both views, so a span expanded in Trace still opens here. - const view = render() + // same way in both views, so a span opened in Trace still opens here — the + // panel is an overlay over the canvas, not a pointer at some node on it. + render() - const drawer = view.container.querySelector('[data-slot="span-drawer"]')! - expect(drawer).toBeTruthy() - expect(within(drawer as HTMLElement).getByText("GET /repo/file")).toBeTruthy() + expect(within(spanPopover()).getByText("GET /repo/file")).toBeTruthy() }) it("merges a run of identical calls into one counted node", () => { @@ -992,38 +1064,38 @@ describe("SessionViews", () => { expect(screen.getByRole("button", { name: /Turn 1/ }).getAttribute("aria-expanded")).toBe("false") }) - // The spec's shared rules: 1/2/3 switch views, and the selection survives a - // Trace ↔ Flow switch because both views address spans the same way. - it("switches views on 2/3 and carries the expanded span across", () => { - const view = render() + // One panel for the whole page, and the page behind it is scrimmed, so the + // way to cross views with a span still open is the panel's own door — which + // keeps both the span and the reader's tab. + it("carries the open span and its tab through the panel's door into Traces", () => { + render() fireEvent.click(screen.getByText("grep_repo")) - expect(view.container.querySelector('[data-slot="span-inline-detail"]')).toBeTruthy() + fireEvent.click(within(spanPopover()).getByRole("button", { name: "Details" })) - fireEvent.keyDown(document.body, { key: "3" }) - expect(view.container.querySelector('[data-slot="span-drawer"]')).toBeTruthy() + fireEvent.click(within(spanPopover()).getByRole("button", { name: "Open in Traces view" })) - fireEvent.keyDown(document.body, { key: "2" }) - expect(view.container.querySelector('[data-slot="span-inline-detail"]')).toBeTruthy() + // The waterfall's own column header: the Traces view is what is on screen. + expect(screen.getByText("Model / target")).toBeTruthy() + expect(spanPopoverCount()).toBe(1) + expect( + within(spanPopover()).getByRole("button", { name: "Details" }).getAttribute("aria-pressed"), + ).toBe("true") }) // The tab choice lives beside the other cross-view state in SessionViews: - // moving the expansion to another span must not reset the reader's tab. - it("keeps the chosen detail tab open when the expansion moves to another span", () => { - const view = render() + // moving the panel to another span must not reset the reader's tab. + it("keeps the chosen detail tab open when the panel moves to another span", () => { + render() // The tool span opens on its own payload (Tool calls); choose Details. fireEvent.click(screen.getByText("grep_repo")) - fireEvent.click(screen.getByRole("button", { name: "Details" })) + fireEvent.click(within(spanPopover()).getByRole("button", { name: "Details" })) - // Move the expansion to a different span — the choice holds. + // Move the panel to a different span — the choice holds. fireEvent.click(screen.getAllByText("read_file")[0]!) - const detail = within(view.container.querySelector('[data-slot="span-inline-detail"]') as HTMLElement) - expect(detail.getByRole("button", { name: "Details" }).getAttribute("aria-pressed")).toBe("true") - - // And it holds across the view switch into the Flow drawer too. - fireEvent.keyDown(document.body, { key: "3" }) - const drawer = within(view.container.querySelector('[data-slot="span-drawer"]') as HTMLElement) - expect(drawer.getByRole("button", { name: "Details" }).getAttribute("aria-pressed")).toBe("true") + expect( + within(spanPopover()).getByRole("button", { name: "Details" }).getAttribute("aria-pressed"), + ).toBe("true") }) }) diff --git a/apps/web/src/components/agent-sessions/session-detail/session-flow.tsx b/apps/web/src/components/agent-sessions/session-detail/session-flow.tsx index 9a5baa1a0..2cc2c872e 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-flow.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-flow.tsx @@ -32,7 +32,8 @@ import { type AiSpanCategory, } from "@/lib/agent-sessions/session-turns" import { filterSpans, isDelegation, shortTarget } from "@/lib/agent-sessions/span-filters" -import { SpanDrawer, type SpanDetailTab } from "./span-expansion" +import type { SpanDetailTab } from "./span-expansion" +import { SpanPopover } from "./span-popover" import { CATEGORY_ICON, CATEGORY_TEXT } from "./span-visuals" // One lane per turn, positioned by hand and handed to `@xyflow/react` — the @@ -53,8 +54,8 @@ const WRAP_GAP = 24 const MIN_ZOOM = 0.5 const MAX_ZOOM = 1.5 /** How far past the graph the canvas can be panned. Roughly half a viewport: - * enough to pull any node clear of the floor's legend and drawer, while a - * fling can never strand the reader on empty canvas with no node in sight. */ + * enough to pull any node clear of the floor's legend, while a fling can never + * strand the reader on empty canvas with no node in sight. */ const PAN_MARGIN = 400 /** Where the hidden ports sit on every card, mirrored by `Ports` below. */ @@ -95,16 +96,16 @@ interface SessionFlowProps { agentSpansOnly: boolean zoom: number onZoomChange: (zoom: number) => void - /** The one span open in the docked drawer (`?span=`). */ + /** The one span open in the popover (`?span=`). */ selectedSpanId: string | undefined - /** Raised with a span id to open the drawer, `undefined` to close it. */ + /** Raised with a span id to open it, `undefined` to close. */ onSelectSpan: (spanId: string | undefined) => void - /** The drawer's tab, shared with the Traces view's inline expansion. */ + /** The popover's tab, shared with the other views. */ spanTab: SpanDetailTab | undefined onSpanTabChange: (tab: SpanDetailTab) => void - /** The session's captured tool results by call id, for the drawer. */ + /** The session's captured tool results by call id, for the popover. */ toolResults?: ReadonlyMap - /** The drawer's "Open in Traces view": same span, sibling view. */ + /** The popover's "Open in Traces view": same span, sibling view. */ onOpenTraceView: () => void } @@ -129,7 +130,7 @@ export function SessionFlow({ const paneRef = useRef(null) const instanceRef = useRef | null>(null) - // Selection addresses spans the same way in both views, so a span expanded + // Selection addresses spans the same way in both views, so a span opened // in the Trace view opens here even when the flow drew no node for it (a // wrapper, or a span the filter hides). const selectedSpan = useMemo(() => { @@ -293,8 +294,8 @@ export function SessionFlow({ {/* The canvas takes whatever height the viewport leaves it (the page column fills the scroller), and xyflow owns panning inside it; the - floor block below stays a sibling so the drawer can dock under the - canvas rather than float over it. */} + floor block below stays a sibling so the legend and zoom sit on the + canvas rather than inside its transformed pane. */}
{lanes.length === 0 ? (

@@ -337,11 +338,10 @@ export function SessionFlow({ )} {/* The view's floor, pinned to the viewport's bottom edge: the legend - and zoom on top, and under them the docked drawer when a span is - open. Sticky rather than absolute so a page grown past the - viewport (a tall drawer) still keeps them on screen. Guarded, - because there is nothing to key, zoom or open when the filter - emptied the canvas. */} + and the zoom controls. Sticky rather than absolute so a page grown + past the viewport still keeps them on screen. Guarded, because + there is nothing to key or zoom when the filter emptied the + canvas. */} {lanes.length > 0 && (

@@ -365,20 +365,20 @@ export function SessionFlow({
- - {selectedSpan !== undefined && ( - onSelectSpan(undefined)} - onOpenTraceView={onOpenTraceView} - /> - )}
)} + + {/* Whether or not the flow drew a node for it: a wrapper span, or one + the filter hides, still opens where it was addressed. */} + onSelectSpan(undefined)} + onOpenTraceView={onOpenTraceView} + />
) @@ -454,7 +454,7 @@ function Ports() { interface StepData extends Record { readonly node: FlowNode readonly selected: boolean - /** Under the keyboard's span cursor — distinct from `selected`, the open drawer. */ + /** Under the keyboard's span cursor — distinct from `selected`, the open panel. */ readonly focused: boolean readonly onSelect: (spanId: string) => void } @@ -473,6 +473,7 @@ const StepNode = memo(function StepNode({ data }: NodeProps & { data: StepData } onClick={() => onSelect(node.span.spanId)} data-span-id={node.span.spanId} aria-current={selected || undefined} + aria-haspopup="dialog" className={cn( "flex size-full cursor-pointer flex-col justify-center gap-1 rounded-md border bg-card px-2.5 py-2 text-left hover:border-ring", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", diff --git a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx index d854d3c2b..ffae0fe37 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx @@ -21,7 +21,10 @@ import { type SessionToolUsage, } from "@/lib/agent-sessions/session-summary" import type { SessionTurn } from "@/lib/agent-sessions/session-turns" +import type { SessionToolResults } from "@/lib/agent-sessions/span-detail" import { shortTarget } from "@/lib/agent-sessions/span-filters" +import type { SpanDetailTab } from "./span-expansion" +import { SpanPopover } from "./span-popover" import { OCCUPANCY_DOT_FILL, OCCUPANCY_FILL, OCCUPANCY_LABEL } from "./span-visuals" const TOKEN_BUCKETS = [ @@ -42,21 +45,42 @@ const SEVERITY_DOT = { * * The page leads with a verdict and a findings list rather than another way to * browse the turns — Traces, Flow and Transcript already do that three ways. - * Every finding links the span that is its evidence, so the Overview is the - * door into the debug views instead of a fourth sibling of them. The facts — - * time bar, cost, tokens, tools — stay, each figure appearing exactly once. + * Every finding opens the span that is its evidence in the inspection overlay, + * over this page rather than instead of it: reading a finding used to cost the + * reader the page. The facts — time bar, cost, tokens, tools — stay, each + * figure appearing exactly once. */ export function SessionOverview({ turns, summary, - onOpenSpan, + selectedSpanId, + onSelectSpan, + spanTab, + onSpanTabChange, + toolResults, + onOpenTraceView, }: { turns: readonly SessionTurn[] summary: SessionSummary - /** Raised with a span id to open it in the Traces view. */ - onOpenSpan: (spanId: string) => void + /** The one span open in the popover (`?span=`). */ + selectedSpanId: string | undefined + /** Raised with a span id to open it, `undefined` to close. */ + onSelectSpan: (spanId: string | undefined) => void + /** The popover's tab, shared with the other views. */ + spanTab: SpanDetailTab | undefined + onSpanTabChange: (tab: SpanDetailTab) => void + /** The session's captured tool results by call id, for the popover. */ + toolResults?: SessionToolResults + /** The popover's "Open in Traces view": same span, sibling view. */ + onOpenTraceView: () => void }) { const report = useMemo(() => buildSessionFindings(turns, summary), [turns, summary]) + const spansById = useMemo( + () => new Map(turns.flatMap((turn) => turn.spans).map((span) => [span.spanId, span])), + [turns], + ) + + const openSpan = (spanId: string) => onSelectSpan(selectedSpanId === spanId ? undefined : spanId) return (
@@ -66,19 +90,28 @@ export function SessionOverview({ verdict={report.verdict} findingCount={report.findings.length} turns={turns} - onOpenSpan={onOpenSpan} + onOpenSpan={openSpan} /> - +
+ + onSelectSpan(undefined)} + onOpenTraceView={onOpenTraceView} + /> ) } @@ -87,6 +120,10 @@ export function SessionOverview({ /* Verdict */ /* -------------------------------------------------------------------------- */ +/** Open a span's payload in the inspection overlay; opening the one already + * open closes it. */ +type OpenSpan = (spanId: string) => void + function Verdict({ verdict, findingCount, @@ -96,7 +133,7 @@ function Verdict({ verdict: SessionVerdict findingCount: number turns: readonly SessionTurn[] - onOpenSpan: (spanId: string) => void + onOpenSpan: OpenSpan }) { const turnWord = turns[0]?.anchorKind === "trace" ? "segment" : "turn" const turnsText = `${turns.length} ${turnWord}${turns.length === 1 ? "" : "s"}` @@ -145,7 +182,12 @@ function Verdict({ )} {verdict.spanId !== undefined && ( - @@ -162,13 +204,7 @@ function VerdictDot({ className }: { className: string }) { /* Findings */ /* -------------------------------------------------------------------------- */ -function Findings({ - findings, - onOpenSpan, -}: { - findings: readonly SessionFinding[] - onOpenSpan: (spanId: string) => void -}) { +function Findings({ findings, onOpenSpan }: { findings: readonly SessionFinding[]; onOpenSpan: OpenSpan }) { return (
@@ -200,16 +236,11 @@ function Findings({ ) } -function FindingRow({ - finding, - onOpenSpan, -}: { - finding: SessionFinding - onOpenSpan: (spanId: string) => void -}) { +function FindingRow({ finding, onOpenSpan }: { finding: SessionFinding; onOpenSpan: OpenSpan }) { return ( @@ -265,7 +296,7 @@ function TurnHealthStrip({ turns: readonly SessionTurn[] health: readonly TurnHealth[] summary: SessionSummary - onOpenSpan: (spanId: string) => void + onOpenSpan: OpenSpan }) { // "with errors", not "failed": a red cell marks a turn something went wrong // INSIDE — the turn itself may have closed cleanly, and calling it failed @@ -295,6 +326,7 @@ function TurnHealthStrip({
+ + onSelectSpan(undefined)} + /> ) } @@ -321,7 +302,6 @@ function buildRows(input: { collapsedTurns: ReadonlySet query: string agentSpansOnly: boolean - selectedSpanId: string | undefined }): readonly WaterfallRow[] { const surviving = input.turns.flatMap((turn) => { const spans = filterSpans(turn.spans, input.query, input.agentSpansOnly) @@ -355,11 +335,6 @@ function buildRows(input: { // turn's own rows split cleanly at the first span that starts after it. flushGaps(spanStartMs(span)) rows.push({ kind: "span", key: `${turn.id}:${span.spanId}`, span, depth }) - // The selected span's payload expands inline, directly under its row — - // one at a time, which is why this is the selection and not a set. - if (span.spanId === input.selectedSpanId) { - rows.push({ kind: "detail", key: `detail:${span.spanId}`, span }) - } } flushGaps(turn.endMs) } @@ -529,7 +504,7 @@ function SpanRow({ axis: SessionAxis spansById: ReadonlyMap selected: boolean - /** Under the keyboard's span cursor — distinct from `selected`, which means expanded. */ + /** Under the keyboard's span cursor — distinct from `selected`, which means open. */ focused: boolean onClick: () => void }) { @@ -549,6 +524,7 @@ function SpanRow({ type="button" onClick={onClick} aria-current={selected || undefined} + aria-haspopup="dialog" aria-expanded={selected} className={cn( "flex h-full w-full cursor-pointer items-center px-2.5 text-left text-xs hover:bg-accent/40", diff --git a/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx b/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx index 16598e8ac..0ec3f613b 100644 --- a/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx @@ -16,10 +16,8 @@ import { ChevronDownIcon, ChevronRightIcon, CircleWarningIcon, - CircleXmarkIcon, CopyIcon, ExternalLinkIcon, - XmarkIcon, } from "@/components/icons" import { MessageResponse } from "@/components/ai-elements/message-response" import { AttributesSection, CopyableValue, ResourceAttributesSection } from "@/components/attributes" @@ -38,35 +36,34 @@ import { type SpanMessagePart, type SpanToolCall, } from "@/lib/agent-sessions/span-detail" -import { classifyAiSpan, spanFailed, spanModel, spanTtftMs } from "@/lib/agent-sessions/session-turns" +import { classifyAiSpan, spanFailed, spanTtftMs } from "@/lib/agent-sessions/session-turns" import { callMetaLine, formatCost } from "@/lib/agent-sessions/session-summary" import { ClampedText, firstLine } from "./clamped-text" import { useJsonPayload, ViewSegment, ViewSwitch } from "./payload-view" import { Pill } from "./pill" -import { CATEGORY_ICON, CATEGORY_TEXT } from "./span-visuals" /** - * The payload of one span, expanded in place — under its waterfall row, or in - * the Flow view's docked drawer. One component for both because the spec's - * whole point is that a span reads the same wherever it was opened; only the - * header differs, and the caller supplies that through `header`. + * The payload of one span. The chrome around it is the caller's — today that is + * `SpanPopover`, the overlay every view opens a span into — and it reaches this + * body through `header`. */ export type SpanDetailTab = "details" | "messages" | "tools" | "logs" +/** The overlay is a reading surface, not a peek, so a payload gets twice the + * transcript's twelve lines before it asks to be expanded. */ +const PANEL_CLAMP = "line-clamp-[24]" + export function SpanExpansion({ span, header, - tabsInHeader = false, tab, onTabChange, toolResults, }: { span: AiSessionSpan - /** Rendered above the tabs; receives the tab strip when `tabsInHeader`. */ - header?: (tabs: ReactNode) => ReactNode - /** Drawer layout: the tab strip rides inside the header row. */ - tabsInHeader?: boolean + /** Rendered above the tab strip. */ + header?: ReactNode /** The reader's tab choice, held by SessionViews so it survives switching * spans and views; `undefined` means none made yet — pick by content. */ tab: SpanDetailTab | undefined @@ -117,129 +114,27 @@ export function SpanExpansion({ ) return ( -
- {header !== undefined && header(tabsInHeader ? tabs : null)} - {!tabsInHeader && ( -
- {tabs} -
- - -
+ // The header and the tab strip are the panel's fixed chrome; only the + // payload under them scrolls, so switching tabs never costs the reader the + // span's name or the way out. +
+ {header} +
+ {tabs} +
+ +
- )} - - - - {active === "details" && } - {active === "messages" && } - {active === "tools" && } - {active === "logs" && } -
- ) -} - -/** The inline form the Traces view mounts under the selected row. */ -export function SpanInlineDetail({ - span, - tab, - onTabChange, - toolResults, -}: { - span: AiSessionSpan - tab: SpanDetailTab | undefined - onTabChange: (tab: SpanDetailTab) => void - toolResults?: SessionToolResults -}) { - return ( -
- -
- ) -} +
-/** The docked drawer the Flow view opens along the bottom of the canvas. */ -export function SpanDrawer({ - span, - turnOrdinal, - tab, - onTabChange, - toolResults, - onClose, - onOpenTraceView, -}: { - span: AiSessionSpan - /** "Turn 3" / "Segment 2" — where the span lives, for the drawer's title row. */ - turnOrdinal: string | undefined - tab: SpanDetailTab | undefined - onTabChange: (tab: SpanDetailTab) => void - toolResults?: SessionToolResults - onClose: () => void - /** Switch to the Traces view with this span still selected. */ - onOpenTraceView: () => void -}) { - const category = classifyAiSpan(span) - const errored = spanFailed(span) - // The canvas the drawer docks under draws its nodes with these glyphs, so the - // drawer names its span in the same vocabulary. - const Glyph = errored ? CircleXmarkIcon : CATEGORY_ICON[category] - const subtitle = [turnOrdinal, spanModel(span), formatDuration(span.durationMs)] - .filter((part): part is string => part !== undefined) - .join(" · ") +
+ - return ( -
- ( -
- - {span.spanName} - {subtitle !== "" && {subtitle}} - {tabs} -
- - - -
-
- )} - /> + {active === "details" && } + {active === "messages" && } + {active === "tools" && } + {active === "logs" && } +
) } @@ -444,6 +339,7 @@ function SystemMessageRow({ message }: { message: SpanMessage }) {
{text}} />
@@ -497,6 +393,7 @@ function MessagePart({ part, raw }: { part: SpanMessagePart; raw: boolean }) { return ( {part.text}} /> ) @@ -523,7 +420,7 @@ function ReasoningPart({ part }: { part: Extract ) : ( - + )}
) @@ -654,7 +551,12 @@ function PayloadBody({ text, copyLabel }: { text: string; copyLabel: string }) { return (
- +
{highlighted !== undefined && ( diff --git a/apps/web/src/components/agent-sessions/session-detail/span-popover.tsx b/apps/web/src/components/agent-sessions/session-detail/span-popover.tsx new file mode 100644 index 000000000..9627d93b5 --- /dev/null +++ b/apps/web/src/components/agent-sessions/session-detail/span-popover.tsx @@ -0,0 +1,139 @@ +import type { AiSessionSpan } from "@maple/domain/http" +import { Button } from "@maple/ui/components/ui/button" +import { Dialog, DialogPopup } from "@maple/ui/components/ui/dialog" +import { formatDuration } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" + +import { CircleXmarkIcon, XmarkIcon } from "@/components/icons" +import { classifyAiSpan, spanFailed, spanModel } from "@/lib/agent-sessions/session-turns" +import type { SessionToolResults } from "@/lib/agent-sessions/span-detail" +import { SpanExpansion, type SpanDetailTab } from "./span-expansion" +import { CATEGORY_ICON, CATEGORY_TEXT } from "./span-visuals" + +/** + * One span, inspected over the whole page. + * + * Every view opens the same panel: the Overview's findings, the flow's nodes, + * the waterfall's rows. It replaced three different chromes — a tab switch, a + * docked drawer and an inline row — that each answered the same question in a + * different place, and each cost the reader the view they were reading it from. + * + * It is an overlay rather than a popover anchored to what was clicked: a + * captured prompt is thousands of tokens, and a panel sized to point at a + * 28px waterfall row made every payload a scroll through a letterbox. The + * backdrop dims the view underneath instead of hiding it — the reader is still + * in the session, one Escape from the row they came from. + * + * Open exactly when the active view has a span selected (`?span=`), which is + * also what makes a pasted link open the panel in whichever view it lands in. + */ +export function SpanPopover({ + span, + turnOrdinal, + tab, + onTabChange, + toolResults, + onClose, + onOpenTraceView, +}: { + /** The selected span, or `undefined` when nothing is open. */ + span: AiSessionSpan | undefined + /** "Turn 3" / "Segment 2" — where the span lives, for the title row. */ + turnOrdinal?: string | undefined + /** The reader's tab choice, held by SessionViews so it survives switching + * spans and views; `undefined` means none made yet — pick by content. */ + tab: SpanDetailTab | undefined + onTabChange: (tab: SpanDetailTab) => void + /** The session's captured tool results by call id (`sessionToolResults`). */ + toolResults?: SessionToolResults + /** Clears the selection: Escape, the close button, a press on the backdrop. */ + onClose: () => void + /** Offered only where the reader is not already in the Traces view. */ + onOpenTraceView?: (() => void) | undefined +}) { + return ( + { + if (!next) onClose() + }} + > + {span !== undefined && ( + + {/* The panel's own handle, for the page's tests and for anything that + needs to find it inside the portal. */} +
+ + } + /> +
+
+ )} +
+ ) +} + +/** Names the span in the same vocabulary the row or node that opened it used, + * and stays put while the payload under it scrolls. */ +function TitleRow({ + span, + turnOrdinal, + onClose, + onOpenTraceView, +}: { + span: AiSessionSpan + turnOrdinal: string | undefined + onClose: () => void + onOpenTraceView: (() => void) | undefined +}) { + const category = classifyAiSpan(span) + const errored = spanFailed(span) + const Glyph = errored ? CircleXmarkIcon : CATEGORY_ICON[category] + const subtitle = [turnOrdinal, spanModel(span), formatDuration(span.durationMs)] + .filter((part): part is string => part !== undefined) + .join(" · ") + + return ( +
+ + {span.spanName} + {subtitle !== "" && {subtitle}} +
+ {onOpenTraceView !== undefined && ( + + )} + +
+
+ ) +} diff --git a/apps/web/src/routes/agent-sessions/$sessionId.tsx b/apps/web/src/routes/agent-sessions/$sessionId.tsx index 4f7337d25..84f972674 100644 --- a/apps/web/src/routes/agent-sessions/$sessionId.tsx +++ b/apps/web/src/routes/agent-sessions/$sessionId.tsx @@ -46,9 +46,9 @@ const agentSessionSearchSchema = Schema.Struct({ // segment on purpose: Back from the detail page returns to the list the // reader came from, not to the view they looked at before this one. view: Schema.optional(Schema.String), - // The span expanded inline (Trace) or open in the docked drawer (Flow). In - // the URL rather than component state so a pasted link reopens the exact - // span someone was looking at, in either debug view. + // The span open in the inspection popover, in whichever view it was opened + // from. In the URL rather than component state so a pasted link reopens the + // exact span someone was looking at. span: Schema.optional(Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed())), }) @@ -214,8 +214,8 @@ function SessionDetailBody({ (spanId: string | undefined) => { navigate({ search: (prev: Record) => ({ ...prev, span: spanId }), - // Expanding a span is a step the reader may want Back to undo; - // moving the expansion, or collapsing it, is not. + // Opening a span is a step the reader may want Back to undo; + // moving the panel to another span, or closing it, is not. replace: search.span !== undefined, }) }, @@ -261,9 +261,9 @@ function SessionDetailBody({ {/* `py-0` (the content blocks carry the padding instead) so the views' sticky elements pin flush to the scroller's edges — sticky offsets resolve against the padding edge. The top edge is the control bar; - the bottom is the Flow view's floor, whose docked span drawer - otherwise floats a padding's height short of the viewport with the - canvas scrolling visibly beneath it. `pr-6` keeps the overlay + the bottom is the Flow view's floor, whose legend and zoom otherwise + float a padding's height short of the viewport with the canvas + scrolling visibly beneath them. `pr-6` keeps the overlay scrollbar off the right-aligned duration/cost columns, and `overflow-x-hidden` means a span that escapes its truncation can never make the whole page scroll sideways. */} @@ -297,8 +297,8 @@ function SessionDetailBody({
- {/* No side panel at any width: span detail expands inline under its - waterfall row, or in the Flow view's docked drawer. */} + {/* No side panel at any width: span detail opens as a popover against + the row, node or finding the reader clicked. */} ) }