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
146 changes: 146 additions & 0 deletions apps/agent-forge/src/ui/run/RunInspector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { ReactNode } from 'react';

import { fmtCost, fmtDuration, fmtInt } from '../../observe/format';
import type { ObserveObservationRow, ObserveScoreRow } from '../../observe/types';
import { JsonView } from '../JsonView';
import { MessageView } from '../MessageView';
import { StatusPill } from '../StatusPill';
import { displayName, displayStatus, elapsedMs, isRunning, playgroundHref } from './rows';
import './run.css';

/**
* Props for {@link RunInspector}. `scores` may be the whole trace's scores:
* the inspector keeps the ones for this observation itself (and, for a root
* observation, the trace-level scores with `obs_id === null`). `now` is the
* clock a running observation's duration counts to (defaults to `Date.now()`).
*/
export interface RunInspectorProps {
observation: ObserveObservationRow;
scores: ObserveScoreRow[];
now?: number;
}

/** `attrs.error` as a string, else `attrs.error.message`, else a generic line. */
function errorText(attrs: Record<string, unknown> | null): string {
const error = attrs?.error;
if (typeof error === 'string' && error !== '') return error;
if (error !== null && typeof error === 'object') {
const message = (error as { message?: unknown }).message;
if (typeof message === 'string' && message !== '') return message;
}
return 'This step failed.';
}

function scoresFor(row: ObserveObservationRow, scores: ObserveScoreRow[]): ObserveScoreRow[] {
const isRoot = row.parent_obs_id === null;
return scores.filter(
(s) => s.obs_id === row.obs_id || (isRoot && s.obs_id === null && s.trace_id === row.trace_id),
);
}

function Section({ id, title, children }: { id: string; title: string; children: ReactNode }) {
return (
<section className={`run-section run-section-${id}`} data-section={id} aria-label={title}>
<h3 className="run-section-title">{title}</h3>
{children}
</section>
);
}

function Feedback({ scores }: { scores: ObserveScoreRow[] }) {
if (scores.length === 0) return <p className="run-empty-line">No feedback on this step yet.</p>;
return (
<ul className="run-scores">
{scores.map((s) => (
<li key={s.score_id} className="run-score">
<span className="run-score-name">{s.name}</span>
<span className="run-score-value">{s.value === null ? '-' : String(s.value)}</span>
<span className="run-score-source">{s.source}</span>
{s.comment && <span className="run-score-comment">{s.comment}</span>}
</li>
))}
</ul>
);
}

function Usage({ row }: { row: ObserveObservationRow }) {
const markers = [row.cost_partial && 'partial', row.usage_estimated && 'estimated'].filter(Boolean);
const facts: [string, ReactNode][] = [
['Input tokens', fmtInt(row.usage_input_tokens)],
['Output tokens', fmtInt(row.usage_output_tokens)],
['Cached tokens', fmtInt(row.usage_cached_tokens)],
['Reasoning tokens', fmtInt(row.usage_reasoning_tokens)],
[
'Cost',
<>
{fmtCost(row.cost_usd)}
{markers.length > 0 && <span className="run-marker"> ({markers.join(', ')})</span>}
</>,
],
['Model', row.model ? <code>{row.model}</code> : '-'],
];
return (
<dl className="run-facts">
{facts.map(([label, value]) => (
<div key={label} className="run-fact">
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
);
}

/**
* The selected observation in detail. Sections, in order: Error (only for a
* failed step: a red `role="alert"` banner) → Input → Output (both
* `MessageView`) → Feedback (scores) → Tokens/Cost/Model → Attributes
* (`JsonView` of `attrs`, omitted when null). A truncated row says so.
*/
export function RunInspector({ observation: row, scores, now }: RunInspectorProps) {
const ms = elapsedMs(row, now ?? Date.now());
return (
<div className="run-inspector" data-obs-id={row.obs_id}>
<header className="run-inspector-head">
<span className="run-inspector-kind">{row.kind}</span>
<h2 className="run-inspector-name">{displayName(row)}</h2>
<StatusPill status={displayStatus(row)} />
<span className="run-dur">
{fmtDuration(ms)}
{isRunning(row) ? ' running' : ''}
</span>
{row.kind === 'generation' && (
<a className="run-playground-link" href={playgroundHref(row.obs_id)}>
Open in Playground
</a>
)}
</header>
{row.status === 'error' && (
<div className="run-error-banner" role="alert" data-section="error">
<strong>Error</strong> {errorText(row.attrs)}
</div>
)}
{row.truncated && (
<p className="run-truncated-note">Truncated: this step&apos;s input or output was cut at ~64 KB by the reporter.</p>
)}
{/* MessageView brings its own labelled header, so no Section title here. */}
<div className="run-section run-section-input" data-section="input">
<MessageView value={row.input} label="Input" />
</div>
<div className="run-section run-section-output" data-section="output">
<MessageView value={row.output} label="Output" />
</div>
<Section id="feedback" title="Feedback">
<Feedback scores={scoresFor(row, scores)} />
</Section>
<Section id="usage" title="Tokens, cost and model">
<Usage row={row} />
</Section>
{row.attrs !== null && (
<Section id="attributes" title="Attributes">
<JsonView value={row.attrs} name="attrs" />
</Section>
)}
</div>
);
}
140 changes: 140 additions & 0 deletions apps/agent-forge/src/ui/run/RunTree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { ReactNode } from 'react';

import { VirtualList } from '../../components/debug/VirtualList';
import { fmtDuration, fmtInt } from '../../observe/format';
import type { ObservationNode } from '../../observe/tree';
import type { ObserveObservationRow } from '../../observe/types';
import { StatusPill } from '../StatusPill';
import {
VIRTUALISE_ABOVE,
VIRTUAL_ROW_HEIGHT,
cx,
displayName,
displayStatus,
elapsedMs,
isRunning,
playgroundHref,
totalTokens,
useFlatNodes,
} from './rows';
import './run.css';

/**
* Props for {@link RunTree}. `observations` are a trace's flat rows as the
* daemon returns them (nested here by `parent_obs_id`); `now` is the clock an
* open observation's running duration counts to (defaults to `Date.now()`).
*/
export interface RunTreeProps {
observations: ObserveObservationRow[];
selectedObsId?: string | null;
onSelect: (obsId: string) => void;
now?: number;
}

const INDENT_PX = 16;

/** One small 16px glyph per observation kind; anything unknown gets the generic dot. */
const KIND_ICON: Record<string, ReactNode> = {
span: <path d="M4 7h16M4 12h10M4 17h6" />,
generation: <path d="M12 3l2.2 5.8L20 11l-5.8 2.2L12 19l-2.2-5.8L4 11l5.8-2.2z" />,
tool: <path d="M14.5 5.5a4 4 0 0 0-5 5L4 16l4 4 5.5-5.5a4 4 0 0 0 5-5l-2.5 2.5-2-2z" />,
event: <path d="M13 3L5 14h6l-1 7 8-11h-6z" />,
};
const GENERIC_ICON = <circle cx="12" cy="12" r="4" />;

function KindIcon({ kind }: { kind: string }) {
const known = Object.hasOwn(KIND_ICON, kind);
return (
<span className={cx('run-kind', `run-kind-${known ? kind : 'other'}`)} data-kind={kind} title={kind}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
{known ? KIND_ICON[kind] : GENERIC_ICON}
</svg>
<span className="visually-hidden">{kind}</span>
</span>
);
}

function Duration({ row, now }: { row: ObserveObservationRow; now: number }) {
const ms = elapsedMs(row, now);
if (isRunning(row)) return <span className="run-dur run-dur-live">{fmtDuration(ms)} running</span>;
return <span className="run-dur">{fmtDuration(ms)}</span>;
}

interface TreeRowProps {
node: ObservationNode;
selected: boolean;
virtual: boolean;
now: number;
onSelect: (obsId: string) => void;
}

function TreeRow({ node, selected, virtual, now, onSelect }: TreeRowProps) {
const { row } = node;
const tokens = totalTokens(row);
const status = displayStatus(row);
const name = displayName(row);
return (
<div
className={cx('run-row', 'run-tree-row', virtual && 'run-row-virtual', selected && 'selected', row.status === 'error' && 'run-row-error')}
data-obs-id={row.obs_id}
data-depth={node.depth}
>
<button
type="button"
className="run-row-main"
style={{ paddingLeft: 8 + node.depth * INDENT_PX }}
aria-current={selected ? 'true' : undefined}
onClick={() => onSelect(row.obs_id)}
>
<KindIcon kind={row.kind} />
<span className="run-name" title={row.name ?? row.obs_id}>
{name}
</span>
{status !== 'ok' && status !== null && <StatusPill status={status} />}
{row.model && <span className="run-model-chip">{row.model}</span>}
{tokens !== null && <span className="run-tokens">{fmtInt(tokens)} tok</span>}
<Duration row={row} now={now} />
</button>
{row.kind === 'generation' && (
<a className="run-playground-link" href={playgroundHref(row.obs_id)} title={`Open ${name} in Playground`}>
Open in Playground
</a>
)}
</div>
);
}

/**
* The trace as an indented tree, one row per observation in depth-first
* order: kind icon, full name (wraps; virtualised rows keep a `title`
* tooltip), status when not ok, model chip, total tokens and duration.
* Generation rows carry an "Open in Playground" link. Above 500 observations
* the rows render through `VirtualList` at a fixed height.
*/
export function RunTree({ observations, selectedObsId, onSelect, now }: RunTreeProps) {
const nodes = useFlatNodes(observations);
const clock = now ?? Date.now();
const virtual = nodes.length > VIRTUALISE_ABOVE;
const renderRow = (node: ObservationNode) => (
<TreeRow
key={node.row.obs_id}
node={node}
selected={node.row.obs_id === selectedObsId}
virtual={virtual}
now={clock}
onSelect={onSelect}
/>
);
if (virtual) {
return (
<div className="run-tree" role="group" aria-label="Run tree">
<VirtualList items={nodes} rowHeight={VIRTUAL_ROW_HEIGHT} renderRow={renderRow} className="run-virtual" />
</div>
);
}
return (
<div className="run-tree" role="group" aria-label="Run tree">
{nodes.map(renderRow)}
</div>
);
}
77 changes: 77 additions & 0 deletions apps/agent-forge/src/ui/run/TraceViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useMemo } from 'react';

import { nestObservations } from '../../observe/tree';
import type { ObserveObservationRow, ObserveScoreRow } from '../../observe/types';
import { EmptyState } from '../EmptyState';
import { RunInspector } from './RunInspector';
import { RunTree } from './RunTree';
import { Waterfall } from './Waterfall';
import './run.css';

/** The two ways {@link TraceViewer} lays out a trace's steps. */
export type TraceViewMode = 'tree' | 'waterfall';

/**
* Props for {@link TraceViewer}. The caller owns both the selection and the
* mode; pass `onModeChange` to get a Tree/Waterfall toggle. `now` is the clock
* open observations run to (defaults to `Date.now()`).
*/
export interface TraceViewerProps {
observations: ObserveObservationRow[];
scores: ObserveScoreRow[];
selectedObsId?: string | null;
onSelect: (obsId: string) => void;
mode: TraceViewMode;
onModeChange?: (mode: TraceViewMode) => void;
now?: number;
}

const MODES: { mode: TraceViewMode; label: string }[] = [
{ mode: 'tree', label: 'Tree' },
{ mode: 'waterfall', label: 'Waterfall' },
];

/** The selected row, else the earliest-started error, else the first root. */
function pickInspected(rows: ObserveObservationRow[], selectedObsId: string | null | undefined): ObserveObservationRow | undefined {
if (selectedObsId != null) {
const selected = rows.find((row) => row.obs_id === selectedObsId);
if (selected) return selected;
}
let firstError: ObserveObservationRow | undefined;
for (const row of rows) {
if (row.status === 'error' && (firstError === undefined || row.start < firstError.start)) firstError = row;
}
return firstError ?? nestObservations(rows)[0]?.row;
}

/**
* The one trace viewer: a `RunTree` or `Waterfall` (per `mode`) beside a
* `RunInspector`. With no valid selection the inspector shows the first
* failed step (so a failed trace opens on its Error banner), else the root.
*/
export function TraceViewer({ observations, scores, selectedObsId, onSelect, mode, onModeChange, now }: TraceViewerProps) {
const inspected = useMemo(() => pickInspected(observations, selectedObsId), [observations, selectedObsId]);
if (inspected === undefined) {
return <EmptyState title="No steps recorded" hint="This trace has no observations yet. Steps appear here as the run reports them." />;
}
const listProps = { observations, selectedObsId: inspected.obs_id, onSelect, now };
return (
<div className="run-viewer" data-mode={mode}>
<div className="run-viewer-steps">
{onModeChange && (
<div className="run-mode-toggle" role="group" aria-label="Trace layout">
{MODES.map((m) => (
<button key={m.mode} type="button" aria-pressed={mode === m.mode} onClick={() => onModeChange(m.mode)}>
{m.label}
</button>
))}
</div>
)}
{mode === 'waterfall' ? <Waterfall {...listProps} /> : <RunTree {...listProps} />}
</div>
<div className="run-viewer-inspector">
<RunInspector observation={inspected} scores={scores} now={now} />
</div>
</div>
);
}
Loading
Loading