[eric] browser: show a failed step as failed in the live overlay, with per-step timestamps

This commit is contained in:
ciregenz
2026-07-31 14:33:16 -07:00
parent dcaf655b14
commit a9da551425
2 changed files with 66 additions and 13 deletions
+6 -1
View File
@@ -2970,7 +2970,12 @@ async def run_browser_agent(
result_text = result.get("text", result.get("error", ""))
result_msg = Message(
role="tool_result",
content={"text": result_text, "tool_name": tu.name, "elapsed_ms": elapsed_ms},
# `ok` because a failure's text lands in the SAME field as a success's, so the
# live overlay was drawing a click that errored exactly like one that worked.
# A flag rather than sniffing the text: "not found" is a perfectly successful
# read, and a string match would call it a failure forever.
content={"text": result_text, "tool_name": tu.name, "elapsed_ms": elapsed_ms,
"ok": "error" not in result},
)
session.messages.append(result_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
@@ -24,11 +24,23 @@ interface Props {
browserHeight: number;
}
function summarizeMessage(msg: AgentMessage): { type: 'thought' | 'action' | 'result' | 'skip'; text: string } {
// Same red as this card's own error icon, so a failed step and a failed run read as one thing.
const P_FAILED_COLOR = '#f87171';
interface LogEntry {
type: 'thought' | 'action' | 'result' | 'skip';
text: string;
/** Wall-clock of the message, so a stalled run shows WHERE it stalled. */
at?: string;
/** Only on a result: did the tool that just ran actually work? */
ok?: boolean;
}
function summarizeMessage(msg: AgentMessage): LogEntry {
if (msg.role === 'assistant' && typeof msg.content === 'string') {
const trimmed = msg.content.trim();
if (!trimmed) return { type: 'skip', text: '' };
return { type: 'thought', text: trimmed };
return { type: 'thought', text: trimmed, at: msg.timestamp };
}
if (msg.role === 'tool_call') {
@@ -46,11 +58,14 @@ function summarizeMessage(msg: AgentMessage): { type: 'thought' | 'action' | 're
case 'BrowserEvaluate': brief = `Evaluate JS`; break;
default: brief = tool;
}
return { type: 'action', text: brief };
return { type: 'action', text: brief, at: msg.timestamp };
}
if (msg.role === 'tool_result') {
return { type: 'result', text: '' };
const content = typeof msg.content === 'string' ? (() => { try { return JSON.parse(msg.content); } catch { return {}; } })() : msg.content;
// Older results predate the `ok` flag; absent means "no reason to think it failed", which keeps
// a resumed session from repainting its whole history red.
return { type: 'result', text: '', ok: content?.ok !== false };
}
return { type: 'skip', text: '' };
@@ -145,9 +160,24 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
const accentColor = c.accent.primary;
const entries = session.messages
.map(summarizeMessage)
.filter((e) => e.type !== 'skip' && e.type !== 'result');
// A result carries no text of its own; its job is to say whether the action just above it
// worked. Fold it back onto that action so a failed click reads as failed instead of vanishing,
// which is what happened before: the overlay dropped results entirely and drew every action the
// same whether it succeeded or errored.
const entries: LogEntry[] = [];
for (const e of session.messages.map(summarizeMessage)) {
if (e.type === 'skip') continue;
if (e.type === 'result') {
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type === 'action') {
if (e.ok === false) entries[i] = { ...entries[i], ok: false };
break;
}
}
continue;
}
entries.push(e);
}
if (streamingMessage && streamingMessage.role === 'assistant' && streamingMessage.content) {
entries.push({ type: 'thought', text: streamingMessage.content });
@@ -439,7 +469,7 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
width: 4,
height: 4,
borderRadius: '1px',
bgcolor: accentColor,
bgcolor: entry.ok === false ? P_FAILED_COLOR : accentColor,
flexShrink: 0,
mt: '5px',
transform: 'rotate(45deg)',
@@ -449,17 +479,35 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
sx={{
fontSize: '0.6875rem',
fontFamily: c.font.mono,
color: accentColor,
color: entry.ok === false ? P_FAILED_COLOR : accentColor,
lineHeight: 1.4,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
// Collapsed stays one tidy line; expanded is where you go to actually READ a
// long selector or URL, so it wraps there instead of ellipsing forever.
...(expanded
? { display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', wordBreak: 'break-all' }
: { textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
}}
>
{entry.text}
{entry.ok === false ? `${entry.text} — failed` : entry.text}
</Typography>
</>
)}
{expanded && entry.at && (
<Typography
sx={{
fontSize: '0.625rem',
fontFamily: c.font.mono,
color: 'rgba(255,255,255,0.28)',
flexShrink: 0,
ml: 'auto',
pl: 0.5,
mt: '4px',
}}
>
{new Date(entry.at).toLocaleTimeString([], { hour12: false })}
</Typography>
)}
</Box>
))}
</Box>