Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand Down Expand Up @@ -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<string, string>
/** The drawer's "Open in Traces view": same span, sibling view. */
/** The popover's "Open in Traces view": same span, sibling view. */
onOpenTraceView: () => void
}

Expand All @@ -129,7 +130,7 @@ export function SessionFlow({
const paneRef = useRef<HTMLDivElement>(null)
const instanceRef = useRef<ReactFlowInstance<Node, Edge> | 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(() => {
Expand Down Expand Up @@ -293,8 +294,8 @@ export function SessionFlow({
<ReactFlowProvider>
{/* 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. */}
<div className="relative flex grow flex-col">
{lanes.length === 0 ? (
<p className="px-2.5 py-8 text-center text-muted-foreground text-sm">
Expand Down Expand Up @@ -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 && (
<div className="sticky bottom-0 z-10 mt-auto flex flex-col">
<div className="pointer-events-none flex items-end justify-between gap-4 p-3">
Expand All @@ -365,20 +365,20 @@ export function SessionFlow({
</div>
<FlowControls zoom={zoom} />
</div>

{selectedSpan !== undefined && (
<SpanDrawer
span={selectedSpan.span}
turnOrdinal={turnOrdinal(selectedSpan.turn)}
tab={spanTab}
onTabChange={onSpanTabChange}
toolResults={toolResults}
onClose={() => onSelectSpan(undefined)}
onOpenTraceView={onOpenTraceView}
/>
)}
</div>
)}

{/* Whether or not the flow drew a node for it: a wrapper span, or one
the filter hides, still opens where it was addressed. */}
<SpanPopover
span={selectedSpan?.span}
turnOrdinal={selectedSpan === undefined ? undefined : turnOrdinal(selectedSpan.turn)}
tab={spanTab}
onTabChange={onSpanTabChange}
toolResults={toolResults}
onClose={() => onSelectSpan(undefined)}
onOpenTraceView={onOpenTraceView}
/>
</div>
</ReactFlowProvider>
)
Expand Down Expand Up @@ -454,7 +454,7 @@ function Ports() {
interface StepData extends Record<string, unknown> {
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
}
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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 (
<div className="@container flex grow flex-col pt-5 pb-10">
Expand All @@ -66,19 +90,28 @@ export function SessionOverview({
verdict={report.verdict}
findingCount={report.findings.length}
turns={turns}
onOpenSpan={onOpenSpan}
onOpenSpan={openSpan}
/>
<Findings findings={report.findings} onOpenSpan={onOpenSpan} />
<Findings findings={report.findings} onOpenSpan={openSpan} />
<TurnHealthStrip
turns={turns}
health={report.turnHealth}
summary={summary}
onOpenSpan={onOpenSpan}
onOpenSpan={openSpan}
/>
<TimeComposition summary={summary} turns={turns} />
</div>
<Rail summary={summary} />
</div>

<SpanPopover
span={selectedSpanId === undefined ? undefined : spansById.get(selectedSpanId)}
tab={spanTab}
onTabChange={onSpanTabChange}
toolResults={toolResults}
onClose={() => onSelectSpan(undefined)}
onOpenTraceView={onOpenTraceView}
/>
</div>
)
}
Expand All @@ -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,
Expand All @@ -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"}`
Expand Down Expand Up @@ -145,7 +182,12 @@ function Verdict({
)}
</div>
{verdict.spanId !== undefined && (
<Button variant="outline" size="sm" onClick={() => onOpenSpan(verdict.spanId!)}>
<Button
variant="outline"
size="sm"
aria-haspopup="dialog"
onClick={() => onOpenSpan(verdict.spanId!)}
>
Open failing span
<ArrowRightIcon size={14} />
</Button>
Expand All @@ -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 (
<section>
<div className="flex items-baseline justify-between gap-2 pb-3.5">
Expand Down Expand Up @@ -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 (
<button
type="button"
aria-haspopup="dialog"
onClick={() => onOpenSpan(finding.spanId)}
className={cn(
"group flex w-full items-start gap-3 border-border border-t px-3 py-3.5 text-left hover:bg-accent/40",
Expand Down Expand Up @@ -239,7 +270,7 @@ function FindingRow({
)}
</span>
<span className="mt-0.5 flex shrink-0 items-center gap-1 text-muted-foreground text-xs opacity-0 transition-opacity group-hover:opacity-100">
trace
inspect
<ArrowRightIcon size={12} />
</span>
</button>
Expand All @@ -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
Expand Down Expand Up @@ -295,6 +326,7 @@ function TurnHealthStrip({
<button
key={turn.id}
type="button"
aria-haspopup="dialog"
onClick={() => onOpenSpan(turn.anchor.spanId)}
title={`${turnOrdinal(turn)}${turn.label === undefined ? "" : ` — ${turn.label}`}`}
className={cn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ export function SessionViews({
summary: SessionSummary
/** The response dropped the END of the session — the transcript says so. */
truncated: boolean
/** The span expanded inline / open in the flow drawer (`?span=`). */
/** The span open in the inspection popover, in whichever view (`?span=`). */
selectedSpanId: string | undefined
/** Raised with a span id to expand it, `undefined` to collapse. */
/** Raised with a span id to open it, `undefined` to close. */
onSelectSpan: (spanId: string | undefined) => void
}) {
const [query, setQuery] = useState("")
Expand All @@ -82,9 +82,9 @@ export function SessionViews({
// keyed by row, and holds the rows flipped AWAY from their default.
const [openRows, setOpenRows] = useState<ReadonlySet<string>>(() => new Set())
const [zoom, setZoom] = useState(1)
// One tab choice for every span expansion, in both debug views: switching
// spans — or Trace ↔ Flow — keeps the reader on the tab they chose.
// `undefined` means no choice yet, and the expansion picks by content.
// One tab choice for every span the popover opens, in every view: switching
// spans — or views — keeps the reader on the tab they chose. `undefined`
// means no choice yet, and the panel picks by content.
const [spanTab, setSpanTab] = useState<SpanDetailTab | undefined>(undefined)

// 1/2/3/4 switch views from anywhere on the page — the switcher stays
Expand Down Expand Up @@ -208,17 +208,20 @@ export function SessionViews({

{/* Overview, Trace and Transcript carry the bottom padding the page
scroller gave up (`pb-0`, so the Flow floor can pin flush — see the
route); the Flow view stays unpadded for the same reason. */}
route); the Flow view stays unpadded for the same reason. Only the
active view sees the span selection: an outgoing panel stays
mounted until its exit transition completes, and two views holding
the inspection overlay open would stack two scrims. */}
<TabsContent value="overview" className="flex flex-[1_1_auto] flex-col pb-4">
<SessionOverview
turns={turns}
summary={summary}
// A finding's evidence lives in the Traces view: select the span and
// go — the waterfall scrolls to and expands the selection on mount.
onOpenSpan={(spanId) => {
onSelectSpan(spanId)
onViewChange("trace")
}}
selectedSpanId={view === "overview" ? selectedSpanId : undefined}
onSelectSpan={onSelectSpan}
spanTab={spanTab}
onSpanTabChange={setSpanTab}
toolResults={toolResults}
onOpenTraceView={() => onViewChange("trace")}
/>
</TabsContent>
<TabsContent value="trace" className="flex flex-[1_1_auto] flex-col pb-4">
Expand All @@ -230,7 +233,7 @@ export function SessionViews({
collapseIdle={collapseIdle}
collapsedTurns={collapsedTurns}
onToggleTurn={(turnId) => setCollapsedTurns((previous) => toggled(previous, turnId))}
selectedSpanId={selectedSpanId}
selectedSpanId={view === "trace" ? selectedSpanId : undefined}
onSelectSpan={onSelectSpan}
spanTab={spanTab}
onSpanTabChange={setSpanTab}
Expand All @@ -245,7 +248,7 @@ export function SessionViews({
agentSpansOnly={agentSpansOnly}
zoom={zoom}
onZoomChange={setZoom}
selectedSpanId={selectedSpanId}
selectedSpanId={view === "flow" ? selectedSpanId : undefined}
onSelectSpan={onSelectSpan}
spanTab={spanTab}
onSpanTabChange={setSpanTab}
Expand Down
Loading
Loading