diff --git a/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx b/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx deleted file mode 100644 index afa393b0..00000000 --- a/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx +++ /dev/null @@ -1,536 +0,0 @@ -import React, { useEffect, useRef, useMemo, useCallback } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import CheckIcon from '@mui/icons-material/Check'; -import CloseIcon from '@mui/icons-material/Close'; -import PanToolOutlinedIcon from '@mui/icons-material/PanToolOutlined'; -import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import LanguageIcon from '@mui/icons-material/Language'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import TouchAppOutlinedIcon from '@mui/icons-material/TouchAppOutlined'; -import KeyboardOutlinedIcon from '@mui/icons-material/KeyboardOutlined'; -import CameraAltOutlinedIcon from '@mui/icons-material/CameraAltOutlined'; -import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined'; -import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined'; -import CodeOutlinedIcon from '@mui/icons-material/CodeOutlined'; -import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; -import { createSelector } from '@reduxjs/toolkit'; -import { shallowEqual } from 'react-redux'; -import { useAppSelector, useAppDispatch } from '@/shared/hooks'; -import { AgentMessage, AgentSession, fetchBrowserAgentChildren, handleApproval } from '@/shared/state/agentsSlice'; -import type { StreamingMessage } from '@/shared/state/streamingSlice'; -import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext'; -import type { RootState } from '@/shared/state/store'; - -interface Props { - parentSessionId: string; - browserId?: string; -} - -interface FeedEntry { - type: 'thought' | 'action' | 'result' | 'system'; - text: string; - actionTool?: string; - sessionLabel?: string; -} - -function formatMessage(msg: AgentMessage): FeedEntry | null { - if (msg.role === 'user') return null; - - if (msg.role === 'assistant' && typeof msg.content === 'string') { - const trimmed = msg.content.trim(); - if (!trimmed) return null; - return { type: 'thought', text: trimmed }; - } - - if (msg.role === 'tool_call') { - const content = - typeof msg.content === 'string' - ? (() => { try { return JSON.parse(msg.content); } catch { return {}; } })() - : msg.content; - const tool = content?.tool || content?.name || '?'; - const input = content?.input || {}; - let brief = ''; - switch (tool) { - case 'BrowserNavigate': - brief = `Navigate → ${input.url || '...'}`; - break; - case 'BrowserClick': - brief = `Click ${input.selector || '...'}`; - break; - case 'BrowserType': { - const txt = (input.text || '').slice(0, 40); - const ellipsis = (input.text || '').length > 40 ? '…' : ''; - brief = `Type "${txt}${ellipsis}" into ${input.selector || '...'}`; - break; - } - case 'BrowserScreenshot': - brief = 'Screenshot'; - break; - case 'BrowserGetText': - brief = 'Read page text'; - break; - case 'BrowserGetElements': - brief = `Inspect elements${input.selector ? ` (${input.selector})` : ''}`; - break; - case 'BrowserEvaluate': - brief = `Evaluate JS`; - break; - default: - brief = `${tool}(${JSON.stringify(input).slice(0, 60)})`; - } - return { type: 'action', text: brief, actionTool: tool }; - } - - if (msg.role === 'tool_result') { - const content = - typeof msg.content === 'string' - ? (() => { try { return JSON.parse(msg.content); } catch { return { text: msg.content }; } })() - : msg.content; - const toolName = content?.tool_name || ''; - const elapsed = content?.elapsed_ms; - const text = content?.text || ''; - - if (toolName === 'BrowserScreenshot') { - return { type: 'result', text: `Screenshot captured${elapsed ? ` (${elapsed}ms)` : ''}` }; - } - const preview = text.length > 120 ? text.slice(0, 120) + '…' : text; - return { type: 'result', text: `${preview}${elapsed ? ` (${elapsed}ms)` : ''}` }; - } - - if (msg.role === 'system') { - return { type: 'system', text: typeof msg.content === 'string' ? msg.content : '' }; - } - - return null; -} - -type SvgIconComponent = typeof OpenInNewIcon; - -function getActionIcon(tool?: string): SvgIconComponent { - switch (tool) { - case 'BrowserNavigate': return OpenInNewIcon; - case 'BrowserClick': return TouchAppOutlinedIcon; - case 'BrowserType': return KeyboardOutlinedIcon; - case 'BrowserScreenshot': return CameraAltOutlinedIcon; - case 'BrowserGetText': return ArticleOutlinedIcon; - case 'BrowserGetElements': return AccountTreeOutlinedIcon; - case 'BrowserEvaluate': return CodeOutlinedIcon; - default: return BuildOutlinedIcon; - } -} - -interface FeedColors { - thought: string; - thoughtIcon: string; - result: string; - error: string; - errorIcon: string; - scrollThumb: string; -} - -const darkFeedColors: FeedColors = { - thought: '#a0aab8', - thoughtIcon: '#555b6e', - result: '#555b6e', - error: '#ff8787', - errorIcon: '#ff8787', - scrollThumb: '#2a2d3e', -}; - -const lightFeedColors: FeedColors = { - thought: '#555550', - thoughtIcon: '#9e9c95', - result: '#9e9c95', - error: '#c03030', - errorIcon: '#c03030', - scrollThumb: '#ccc9c0', -}; - -// Stable ref keeps shallowEqual happy when there are no browser sessions yet. -const EMPTY_STREAMING: Record = Object.freeze({}) as Record; - -// Factory, one selector PER FEED: a module-level createSelector has a cache of 1 shared by every mounted feed, so two feeds with different args thrash it and every render recomputes (and returns a fresh array identity, which defeats all downstream memoization). -const makeSelectBrowserSessions = () => createSelector( - [(state: RootState) => state.agents.sessions, - (_: RootState, parentSessionId: string) => parentSessionId, - (_: RootState, __: string, browserId?: string) => browserId], - (sessions, parentSessionId, browserId) => - Object.values(sessions).filter( - (s): s is AgentSession => - s.mode === 'browser-agent' && - s.parent_session_id === parentSessionId && - (!browserId || s.browser_id === browserId), - ), - // Same members = same array identity: ANY session update rebuilds the sessions dict, and without this every unrelated agent:status re-ran formatMessage over the whole feed history. - { memoizeOptions: { resultEqualityCheck: shallowEqual } }, -); - -const BrowserAgentInlineFeed: React.FC = ({ parentSessionId, browserId }) => { - const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - const { mode } = useThemeMode(); - const fc = mode === 'dark' ? darkFeedColors : lightFeedColors; - const scrollRef = useRef(null); - const fetchedForSession = useRef(null); - - const selectBrowserSessions = useMemo(makeSelectBrowserSessions, []); - const browserSessions = useAppSelector((state) => - selectBrowserSessions(state, parentSessionId, browserId), - ); - // Subscribe only to this feed's sessions; reading the full streaming dict re-renders on every char from every agent. - const browserSessionIds = useMemo( - () => browserSessions.map((s) => s.id).sort().join(','), - [browserSessions], - ); - const streamingBySession = useAppSelector( - (state) => { - if (!browserSessionIds) return EMPTY_STREAMING; - const out: Record = {}; - for (const id of browserSessionIds.split(',')) { - const entry = state.streaming.bySession[id]; - if (entry) out[id] = entry; - } - return out; - }, - shallowEqual, - ); - - // A child that arrived only through the trimmed session-list poll carries its message_count but no messages; fetch the full children so its history renders instead of showing a blank feed. Keyed by the unhydrated-children set (not one-shot per parent) so a NEW child appearing mid-run still hydrates, while the same set never refetches (no loop). - const unhydratedKey = browserSessions.length === 0 - ? `${parentSessionId}:empty` - : browserSessions.filter((s) => (s.message_count ?? 0) > 0 && s.messages.length === 0).map((s) => s.id).sort().join(','); - useEffect(() => { - if (!unhydratedKey.endsWith(':empty') && unhydratedKey === '') return; - if (fetchedForSession.current === unhydratedKey) return; - fetchedForSession.current = unhydratedKey; - dispatch(fetchBrowserAgentChildren(parentSessionId)) - .unwrap() - .catch(() => { fetchedForSession.current = null; }); - }, [unhydratedKey, parentSessionId, dispatch]); - - const sessionsWithHistoricalEntries = useMemo(() => { - return browserSessions.map((session) => { - const entries: FeedEntry[] = []; - for (const msg of session.messages) { - const entry = formatMessage(msg); - if (!entry) continue; - // Retries emit the same status line back-to-back ("Picking up what I learned..." x4); one - // row carries the information, the repeats were pure noise in the transcript. - const prev = entries[entries.length - 1]; - if (prev && prev.type === entry.type && prev.text === entry.text) continue; - entries.push(entry); - } - return { session, entries }; - }); - }, [browserSessions]); - - const sessionsWithEntries = sessionsWithHistoricalEntries.map(({ session, entries }) => { - const stream: StreamingMessage | undefined = streamingBySession[session.id]; - if (stream?.role === 'assistant' && stream.content) { - return { session, entries: [...entries, { type: 'thought' as const, text: stream.content }] }; - } - return { session, entries }; - }); - - const totalMessages = browserSessions.reduce( - (n, s) => n + s.messages.length + (streamingBySession[s.id] ? 1 : 0), - 0, - ); - - const isStuckToBottom = useRef(true); - - const handleScroll = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - isStuckToBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 30; - }, []); - - useEffect(() => { - if (isStuckToBottom.current && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [totalMessages]); - - if (browserSessions.length === 0) return null; - - const showLabels = sessionsWithEntries.length > 1; - const accentColor = c.accent.primary; - - return ( - { - // Block wheel only while feed can still scroll; at boundaries let parent chat take over. - const el = scrollRef.current; - if (!el) return; - const atTop = el.scrollTop <= 0 && e.deltaY < 0; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 1 && e.deltaY > 0; - if (!atTop && !atBottom) e.stopPropagation(); - }} - sx={{ - maxHeight: 300, - overflowY: 'auto', - px: 1.5, - py: 1, - display: 'flex', - flexDirection: 'column', - gap: 0.25, - scrollbarWidth: 'thin', - scrollbarColor: `${fc.scrollThumb} transparent`, - '&::-webkit-scrollbar': { width: 4 }, - '&::-webkit-scrollbar-thumb': { - background: fc.scrollThumb, - borderRadius: 2, - }, - }} - > - {sessionsWithEntries.map(({ session, entries }, si) => ( - - {showLabels && ( - 0 ? 1 : 0, mb: 0.25 }}> - - - {session.browser_id || `Browser ${si + 1}`} - - - - )} - - {!showLabels && entries.length === 0 && session.status === 'running' && ( - - Starting browser agent... - - )} - - {entries.map((entry, i) => ( - - ))} - - {session.pending_approvals?.filter( - (a) => a.tool_name === 'RequestHumanIntervention', - ).map((intervention) => { - const problem = (intervention.tool_input as any)?.problem || 'Browser agent needs help'; - return ( - - - - {problem} - - - dispatch(handleApproval({ requestId: intervention.id, behavior: 'allow' }))} - sx={{ - p: 0, - width: 18, - height: 18, - color: '#fff', - bgcolor: '#f59e0b', - '&:hover': { bgcolor: '#d97706' }, - }} - > - - - - - dispatch(handleApproval({ requestId: intervention.id, behavior: 'deny', message: 'User declined to help' }))} - sx={{ - p: 0, - width: 18, - height: 18, - color: '#f59e0b', - border: '1px solid rgba(245,158,11,0.4)', - '&:hover': { bgcolor: 'rgba(245,158,11,0.1)' }, - }} - > - - - - - ); - })} - - {!showLabels && session.status === 'running' && entries.length > 0 && ( - - - - )} - - ))} - - ); -}; - -// Memoized: the feed re-renders on every streamed token, and un-memoized rows re-render the ENTIRE lazy-loaded history per token (the "browser use = hella lag" bug). -const EntryRow = React.memo<{ entry: FeedEntry; accentColor: string; fc: FeedColors }>(({ entry, accentColor, fc }) => { - const c = useClaudeTokens(); - - if (entry.type === 'thought') { - return ( - - - - {entry.text} - - - ); - } - - if (entry.type === 'action') { - const ActionIcon = getActionIcon(entry.actionTool); - return ( - - - - {entry.text} - - - ); - } - - if (entry.type === 'result') { - return ( - - - ↳ {entry.text} - - - ); - } - - if (entry.type === 'system') { - return ( - - - - {entry.text} - - - ); - } - - return null; -}); - -const SessionStatusChip: React.FC<{ status: string }> = ({ status }) => { - const c = useClaudeTokens(); - if (status === 'running') { - return ( - - ); - } - if (status === 'completed') { - return ; - } - if (status === 'error') { - return ; - } - return null; -}; - -export default React.memo(BrowserAgentInlineFeed);