diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index cab14cec..e6e01968 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -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", { diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserAgentOverlay.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserAgentOverlay.tsx index 450b231c..fbe45070 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserAgentOverlay.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserAgentOverlay.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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} )} + {expanded && entry.at && ( + + {new Date(entry.at).toLocaleTimeString([], { hour12: false })} + + )} ))}