Skip to content

Commit 94c783d

Browse files
committed
Hold every column in an object header's metrics strip
Ports the MetaStrip from activeagents#470 so the evaluation runs list can keep its cost, movement and status columns aligned on every row — a failed run with nothing to put in a column prints a dash there rather than sliding its neighbours over. The trace and interaction lists take the same strip, as that change has it; the source is identical to the branch so it no-ops once activeagents#470 merges. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QmHbHdDkfVrg3WX95CnFhd
1 parent 8326559 commit 94c783d

3 files changed

Lines changed: 215 additions & 58 deletions

File tree

‎actionagent/frontend/components/dashboard/InteractionsView.jsx‎

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { formatDuration } from './SpanWaterfall';
1313
import { dashboardPath } from '../../utils/dashboardPath';
1414
import {
1515
Chevron,
16+
META_COLUMN,
17+
MetaStrip,
1618
ObjectCard,
1719
PreviewLines,
1820
SegmentedControl,
@@ -549,6 +551,10 @@ export default function InteractionsView({ agentId = null, embedded = false }) {
549551
const detail = details[session.id];
550552
const isExpanded = expandedSession === session.id;
551553
const sessionView = sessionViews[session.id] || 'conversation';
554+
const sessionTokens = session.tokens?.total || 0;
555+
// What the meter holds against the window: the in/out counts when
556+
// the interaction reported them, the total when it only has that.
557+
const sessionContext = (session.tokens?.input || 0) + (session.tokens?.output || 0) || sessionTokens;
552558
return (
553559
<ObjectCard key={session.id} darkMode={darkMode} className="shadow-sm">
554560
{/* Session header — the same object header a trace card uses:
@@ -586,24 +592,53 @@ export default function InteractionsView({ agentId = null, embedded = false }) {
586592
</span>
587593
)}
588594
</div>
589-
<div className="flex items-center gap-4 flex-shrink-0 text-sm" style={{ color: colors.textSecondary }}>
590-
<span>
591-
{session.message_count > 0
592-
? `${session.message_count} messages`
593-
: `${session.tool_count || 0} tool ${session.tool_count === 1 ? 'call' : 'calls'}`}
594-
</span>
595-
<span>{formatNumber(session.tokens?.total)} tokens</span>
596-
{(session.tokens?.total || 0) > 0 && (
597-
<ContextMeter
598-
compact
599-
darkMode={darkMode}
600-
label="Context"
601-
used={(session.tokens?.input || 0) + (session.tokens?.output || 0) || session.tokens?.total}
602-
limit={contextWindowFor(session.model)}
603-
segments={[{ key: 'messages', label: 'Context', tokens: (session.tokens?.input || 0) + (session.tokens?.output || 0) || session.tokens?.total }]}
604-
/>
605-
)}
606-
<span style={{ color: colors.textMuted }}>{timeAgo(session.last_activity_at)}</span>
595+
{/* The same columns a trace row keeps, held whether or
596+
not this interaction filled them. */}
597+
<div className="flex items-center gap-4 flex-shrink-0">
598+
<MetaStrip
599+
darkMode={darkMode}
600+
cells={[
601+
{
602+
key: 'activity',
603+
width: META_COLUMN.count,
604+
content:
605+
session.message_count > 0
606+
? `${session.message_count} messages`
607+
: `${session.tool_count || 0} tool ${session.tool_count === 1 ? 'call' : 'calls'}`,
608+
},
609+
{
610+
key: 'tokens',
611+
width: META_COLUMN.tokens,
612+
title: 'Tokens across this interaction',
613+
empty: 'No token counts recorded for this interaction',
614+
content: sessionTokens > 0 && `${formatNumber(sessionTokens)} tokens`,
615+
},
616+
{
617+
key: 'context',
618+
width: META_COLUMN.context,
619+
empty: 'Context: this interaction recorded no token counts',
620+
content: sessionTokens > 0 && (
621+
<ContextMeter
622+
compact
623+
darkMode={darkMode}
624+
label="Context"
625+
used={sessionContext}
626+
limit={contextWindowFor(session.model)}
627+
segments={[{ key: 'messages', label: 'Context', tokens: sessionContext }]}
628+
/>
629+
),
630+
},
631+
{
632+
key: 'age',
633+
width: META_COLUMN.age,
634+
title: session.last_activity_at || undefined,
635+
empty: 'No activity recorded',
636+
content: timeAgo(session.last_activity_at) && (
637+
<span style={{ color: colors.textMuted }}>{timeAgo(session.last_activity_at)}</span>
638+
),
639+
},
640+
]}
641+
/>
607642
<Chevron open={isExpanded} darkMode={darkMode} />
608643
</div>
609644
</div>
@@ -614,6 +649,7 @@ export default function InteractionsView({ agentId = null, embedded = false }) {
614649
<PreviewLines
615650
darkMode={darkMode}
616651
onClick={() => toggleSession(session.id)}
652+
hold
617653
style={{ padding: '0 16px 12px' }}
618654
lines={[
619655
{ label: 'input', text: session.preview?.input, color: roleBubble('user', darkMode).color },

‎actionagent/frontend/components/dashboard/TelemetryObject.jsx‎

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,71 @@ export function ObjectCard({ darkMode, id, children, style, className = '' }) {
7373
);
7474
}
7575

76+
// The metrics strip down the right of an object header: what a run used —
77+
// model, context pressure, wall clock, tokens, cost, status — one column each.
78+
//
79+
// A list of these rows only reads as columns when every row puts its cells in
80+
// the same place, and rendering only the values an object happens to carry
81+
// breaks that on exactly the rows you most want to read against their
82+
// neighbours. An errored trace has no tokens, no cost and nothing in context,
83+
// so its surviving cells used to slide rightward into the columns the healthy
84+
// rows spend on something else — its duration under their cost, its status
85+
// under nothing at all. Every cell here holds its column whether or not this
86+
// object filled it, and an empty one prints a muted dash: "not recorded", in
87+
// the place you were already looking. The strip is anchored at its right edge,
88+
// so only the first column is free to size to its content — a model name is
89+
// the one value with no sensible fixed width. Each column is wide enough for
90+
// the longest value it holds, with the icon that leads it: "155.6K tokens",
91+
// "$0.0996", "12.34s".
92+
export const META_COLUMN = {
93+
context: 132, // the compact ContextMeter's own width
94+
duration: 76,
95+
tokens: 104,
96+
cost: 88,
97+
status: 68,
98+
count: 96,
99+
age: 76,
100+
};
101+
102+
// `cells` are `{ key, content, width, title, empty, align }`: `width` in
103+
// pixels (omitted only for a leading content-sized column), `title` the
104+
// tooltip when there is a value and `empty` the one explaining its absence.
105+
export function MetaStrip({ cells, darkMode, gap = 16, className = '', style }) {
106+
const colors = telemetryColors(darkMode);
107+
const shown = (cells || []).filter(Boolean);
108+
if (shown.length === 0) return null;
109+
110+
return (
111+
<div
112+
className={`flex-shrink-0 text-sm ${className}`}
113+
style={{
114+
display: 'grid',
115+
gridTemplateColumns: shown.map((cell) => (cell.width ? `${cell.width}px` : 'minmax(0, auto)')).join(' '),
116+
alignItems: 'center',
117+
columnGap: `${gap}px`,
118+
color: colors.textSecondary,
119+
...style,
120+
}}
121+
>
122+
{shown.map((cell) => {
123+
const filled = cell.content != null && cell.content !== false && cell.content !== '';
124+
return (
125+
<div
126+
key={cell.key}
127+
title={(filled ? cell.title : cell.empty) || undefined}
128+
// A column is one line wide: a long model name ellipses rather
129+
// than widening a column every other row has to match.
130+
className="min-w-0 truncate"
131+
style={{ textAlign: cell.align || 'right' }}
132+
>
133+
{filled ? cell.content : <span style={{ color: colors.textMuted }}>—</span>}
134+
</div>
135+
);
136+
})}
137+
</div>
138+
);
139+
}
140+
76141
// The block an expanded value opens into. JSON keeps its indentation and
77142
// scrolls sideways; prose wraps. Either way it is capped and scrolls, because
78143
// a captured message history can run to thousands of lines and would otherwise
@@ -116,11 +181,18 @@ export const prettyValue = (value) => {
116181
// `onClick` opens the object these lines belong to. It still fires for a line
117182
// with nothing more to show, and for the padding around them — only a line
118183
// that can actually open keeps the click for itself.
119-
export function PreviewLines({ lines, darkMode, onClick, indent = 0, size = '12px', style, max = 200 }) {
184+
// `hold` keeps a line whose value is missing, as a labelled empty row. Rows in
185+
// a list want it — a trace that errored before it answered still prints its
186+
// `output:` line, so its card stands as tall as the ones around it and the two
187+
// labels stay on one baseline down the list. A span inside an open trace does
188+
// not: its missing half is usually one the panel above already showed, and a
189+
// dash there would deny content the span really carried.
190+
export function PreviewLines({ lines, darkMode, onClick, indent = 0, size = '12px', style, max = 200, hold = false }) {
120191
const [isOpen, toggle] = useDisclosureSet();
121192
const colors = telemetryColors(darkMode);
122-
const visible = (lines || []).filter((line) => line && line.text);
123-
if (visible.length === 0) return null;
193+
const shown = (lines || []).filter((line) => line && (line.text || hold));
194+
// An object that recorded neither half keeps no preview block at all.
195+
if (!shown.some((line) => line.text)) return null;
124196

125197
return (
126198
<div
@@ -137,11 +209,12 @@ export function PreviewLines({ lines, darkMode, onClick, indent = 0, size = '12p
137209
...style,
138210
}}
139211
>
140-
{visible.map((line, index) => {
212+
{shown.map((line, index) => {
141213
const key = `${line.label}-${index}`;
142214
const open = isOpen(key);
143215
const limit = line.max || max;
144-
const squished = String(line.text).replace(/\s+/g, ' ').trim();
216+
const empty = !line.text;
217+
const squished = String(line.text ?? '').replace(/\s+/g, ' ').trim();
145218
const pretty = prettyValue(line.text);
146219
// These rows are a single clipped line, so what actually fits depends
147220
// on the window — a character count can't tell you. The rule errs
@@ -150,7 +223,7 @@ export function PreviewLines({ lines, darkMode, onClick, indent = 0, size = '12p
150223
// to fit costs nothing next to a clipped line with no way in. JSON
151224
// always opens — its indented form is the readable one at any width.
152225
const expandable =
153-
pretty.json || squished.length > 60 || squished !== String(line.text).trim();
226+
!empty && (pretty.json || squished.length > 60 || squished !== String(line.text).trim());
154227

155228
return (
156229
<div key={key}>
@@ -180,7 +253,7 @@ export function PreviewLines({ lines, darkMode, onClick, indent = 0, size = '12p
180253
<span style={{ color: line.color || colors.textMuted }}>
181254
{expandable ? (open ? '▾' : '▸') : '\u00A0'} {line.label}:
182255
</span>{' '}
183-
{!open && previewText(squished, limit)}
256+
{!open && (empty ? <span style={{ color: colors.textMuted }}>—</span> : previewText(squished, limit))}
184257
</div>
185258
{open && (
186259
<pre

‎actionagent/frontend/components/dashboard/TraceCard.jsx‎

Lines changed: 81 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@ import ContextMeter from './ContextMeter';
44
import TraceDetail, { traceContext } from './TraceDetail';
55
import { formatDuration, traceContentPreview } from './SpanWaterfall';
66
import { roleBubble } from './InteractionStream';
7-
import { Chevron, ObjectCard, PreviewLines, telemetryColors } from './TelemetryObject';
7+
import { Chevron, META_COLUMN, MetaStrip, ObjectCard, PreviewLines, telemetryColors } from './TelemetryObject';
88

99
// One trace as an expandable object: what ran, what it cost, what went in and
1010
// came out — then the full waterfall or conversation underneath. The same card
1111
// renders in both themes; the Traces view used to keep a separate dark markup
1212
// tree that had drifted a long way from the light one.
1313

14+
// Null when the trace carries no counts at all: a run that failed before the
15+
// provider answered spent an unknown amount, not zero, and the strip says so
16+
// with a dash rather than claiming a number nobody recorded.
1417
const formatTokens = (tokens) => {
15-
if (!tokens) return '0';
16-
const total = (tokens.input || 0) + (tokens.output || 0) + (tokens.thinking || 0);
18+
const total = (tokens?.input || 0) + (tokens?.output || 0) + (tokens?.thinking || 0);
19+
if (!total) return null;
1720
if (total >= 1000) return `${(total / 1000).toFixed(1)}K`;
1821
return `${total}`;
1922
};
@@ -27,6 +30,7 @@ export default function TraceCard({ trace, darkMode, expanded, onToggle }) {
2730
const preview = traceContentPreview(trace);
2831
const context = traceContext(trace);
2932
const succeeded = trace.status !== 'ERROR';
33+
const tokens = formatTokens(trace.tokens);
3034

3135
return (
3236
<ObjectCard darkMode={darkMode} id={`trace-row-${trace.id}`}>
@@ -51,36 +55,79 @@ export default function TraceCard({ trace, darkMode, expanded, onToggle }) {
5155
{trace.display_name}
5256
</span>
5357
</div>
54-
<div className="flex items-center gap-4 flex-shrink-0 text-sm" style={{ color: colors.textSecondary }}>
55-
{trace.model && (
56-
<span
57-
className="text-xs px-2 py-0.5 rounded"
58-
style={{
59-
fontFamily: TYPOGRAPHY.mono,
60-
background: darkMode ? 'rgba(99,102,241,0.2)' : '#eef2ff',
61-
color: darkMode ? '#a5b4fc' : '#4338ca',
62-
}}
63-
title="Model that generated this trace"
64-
>
65-
{trace.model}
66-
</span>
67-
)}
68-
{context && <ContextMeter compact {...context} label="Context" darkMode={darkMode} />}
69-
<span>
70-
<i className="fa-solid fa-clock mr-1"></i>
71-
{formatDuration(trace.duration_ms)}
72-
</span>
73-
<span>{formatTokens(trace.tokens)} tokens</span>
74-
{trace.estimated_cost != null && (
75-
<span>
76-
<i className="fa-solid fa-coins mr-1"></i>
77-
{formatCost(trace.estimated_cost)}
78-
</span>
79-
)}
80-
<span style={{ color: succeeded ? colors.good : colors.bad }}>
81-
<i className={`fa-solid ${succeeded ? 'fa-check' : 'fa-xmark'} mr-1`}></i>
82-
{succeeded ? 'OK' : 'ERROR'}
83-
</span>
58+
{/* One strip, the same columns on every row — including the rows
59+
with nothing to put in them. */}
60+
<div className="flex items-center gap-4 flex-shrink-0">
61+
<MetaStrip
62+
darkMode={darkMode}
63+
cells={[
64+
{
65+
key: 'model',
66+
title: 'Model that generated this trace',
67+
empty: 'No model recorded for this trace',
68+
content: trace.model && (
69+
<span
70+
className="text-xs px-2 py-0.5 rounded"
71+
style={{
72+
fontFamily: TYPOGRAPHY.mono,
73+
background: darkMode ? 'rgba(99,102,241,0.2)' : '#eef2ff',
74+
color: darkMode ? '#a5b4fc' : '#4338ca',
75+
}}
76+
>
77+
{trace.model}
78+
</span>
79+
),
80+
},
81+
{
82+
key: 'context',
83+
width: META_COLUMN.context,
84+
empty: 'Context: no generation in this trace reported its token counts',
85+
content: context && <ContextMeter compact {...context} label="Context" darkMode={darkMode} />,
86+
},
87+
{
88+
key: 'duration',
89+
width: META_COLUMN.duration,
90+
title: 'Wall-clock duration',
91+
empty: 'No duration recorded for this trace',
92+
content: trace.duration_ms != null && (
93+
<>
94+
<i className="fa-solid fa-clock mr-1"></i>
95+
{formatDuration(trace.duration_ms)}
96+
</>
97+
),
98+
},
99+
{
100+
key: 'tokens',
101+
width: META_COLUMN.tokens,
102+
title: 'Tokens in, out and thinking',
103+
empty: 'No token counts recorded for this trace',
104+
content: tokens && `${tokens} tokens`,
105+
},
106+
{
107+
key: 'cost',
108+
width: META_COLUMN.cost,
109+
title: 'Estimated cost of this trace',
110+
empty: 'Nothing to price — this trace recorded no tokens',
111+
content: trace.estimated_cost != null && (
112+
<>
113+
<i className="fa-solid fa-coins mr-1"></i>
114+
{formatCost(trace.estimated_cost)}
115+
</>
116+
),
117+
},
118+
{
119+
key: 'status',
120+
width: META_COLUMN.status,
121+
title: succeeded ? 'Trace completed' : trace.error || 'Trace ended in an error',
122+
content: (
123+
<span style={{ color: succeeded ? colors.good : colors.bad }}>
124+
<i className={`fa-solid ${succeeded ? 'fa-check' : 'fa-xmark'} mr-1`}></i>
125+
{succeeded ? 'OK' : 'ERROR'}
126+
</span>
127+
),
128+
},
129+
]}
130+
/>
84131
<Chevron open={expanded} darkMode={darkMode} />
85132
</div>
86133
</div>
@@ -89,6 +136,7 @@ export default function TraceCard({ trace, darkMode, expanded, onToggle }) {
89136
<PreviewLines
90137
darkMode={darkMode}
91138
onClick={onToggle}
139+
hold
92140
style={{ padding: '0 16px 12px' }}
93141
lines={[
94142
{ label: 'input', text: preview.input, color: roleBubble('user', darkMode).color },

0 commit comments

Comments
 (0)