[eric] browser: delete the orphaned BrowserAgentInlineFeed component (missed in the action-feed removal commit)

This commit is contained in:
ciregenz
2026-08-08 01:22:54 -07:00
parent a12047ae66
commit a0e7e719fa
@@ -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<string, StreamingMessage> = Object.freeze({}) as Record<string, StreamingMessage>;
// 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<Props> = ({ parentSessionId, browserId }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { mode } = useThemeMode();
const fc = mode === 'dark' ? darkFeedColors : lightFeedColors;
const scrollRef = useRef<HTMLDivElement>(null);
const fetchedForSession = useRef<string | null>(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<string, StreamingMessage> = {};
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 (
<Box
ref={scrollRef}
onScroll={handleScroll}
onWheel={(e) => {
// 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) => (
<Box key={session.id} sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{showLabels && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: si > 0 ? 1 : 0, mb: 0.25 }}>
<LanguageIcon sx={{ fontSize: 12, color: accentColor, opacity: 0.7 }} />
<Typography
sx={{
fontSize: '0.625rem',
fontWeight: 600,
color: accentColor,
opacity: 0.8,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
{session.browser_id || `Browser ${si + 1}`}
</Typography>
<SessionStatusChip status={session.status} />
</Box>
)}
{!showLabels && entries.length === 0 && session.status === 'running' && (
<Typography
sx={{
fontSize: '0.6875rem',
color: c.text.tertiary,
fontStyle: 'italic',
fontFamily: c.font.mono,
}}
>
Starting browser agent...
</Typography>
)}
{entries.map((entry, i) => (
<EntryRow key={i} entry={entry} accentColor={accentColor} fc={fc} />
))}
{session.pending_approvals?.filter(
(a) => a.tool_name === 'RequestHumanIntervention',
).map((intervention) => {
const problem = (intervention.tool_input as any)?.problem || 'Browser agent needs help';
return (
<Box
key={intervention.id}
sx={{
mt: 0.75,
mb: 0.5,
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1,
py: 0.5,
borderRadius: '8px',
bgcolor: 'rgba(245,158,11,0.10)',
border: '1px solid rgba(245,158,11,0.25)',
}}
>
<PanToolOutlinedIcon sx={{ fontSize: 12, color: '#f59e0b', flexShrink: 0, mt: '2px' }} />
<Typography
sx={{
fontSize: '0.75rem',
fontWeight: 600,
color: '#f59e0b',
flex: 1,
minWidth: 0,
lineHeight: 1.4,
}}
>
{problem}
</Typography>
<Tooltip title="Done, continue" arrow>
<IconButton
size="small"
onClick={() => dispatch(handleApproval({ requestId: intervention.id, behavior: 'allow' }))}
sx={{
p: 0,
width: 18,
height: 18,
color: '#fff',
bgcolor: '#f59e0b',
'&:hover': { bgcolor: '#d97706' },
}}
>
<CheckIcon sx={{ fontSize: 11 }} />
</IconButton>
</Tooltip>
<Tooltip title="Skip" arrow>
<IconButton
size="small"
onClick={() => 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)' },
}}
>
<CloseIcon sx={{ fontSize: 11 }} />
</IconButton>
</Tooltip>
</Box>
);
})}
{!showLabels && session.status === 'running' && entries.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: accentColor,
animation: 'ba-feed-pulse 1.4s ease-in-out infinite',
'@keyframes ba-feed-pulse': {
'0%, 100%': { opacity: 0.3, transform: 'scale(0.8)' },
'50%': { opacity: 1, transform: 'scale(1.2)' },
},
}}
/>
</Box>
)}
</Box>
))}
</Box>
);
};
// 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 (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0 }}>
<SmartToyOutlinedIcon
sx={{ fontSize: 10, color: fc.thoughtIcon, mt: '3px', flexShrink: 0 }}
/>
<Typography
sx={{
fontSize: '0.6875rem',
color: fc.thought,
lineHeight: 1.45,
wordBreak: 'break-word',
fontFamily: c.font.mono,
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'action') {
const ActionIcon = getActionIcon(entry.actionTool);
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0 }}>
<ActionIcon sx={{ fontSize: 11, color: accentColor, mt: '2px', flexShrink: 0 }} />
<Typography
sx={{
fontSize: '0.6875rem',
fontFamily: c.font.mono,
color: accentColor,
lineHeight: 1.45,
wordBreak: 'break-word',
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'result') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0, pl: 1.25 }}>
<Typography
sx={{
fontSize: '0.625rem',
fontFamily: c.font.mono,
color: fc.result,
lineHeight: 1.45,
wordBreak: 'break-word',
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'system') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', minWidth: 0 }}>
<ErrorOutlineIcon sx={{ fontSize: 10, color: fc.errorIcon, flexShrink: 0 }} />
<Typography
sx={{
fontSize: '0.6875rem',
fontFamily: c.font.mono,
color: fc.error,
lineHeight: 1.45,
}}
>
{entry.text}
</Typography>
</Box>
);
}
return null;
});
const SessionStatusChip: React.FC<{ status: string }> = ({ status }) => {
const c = useClaudeTokens();
if (status === 'running') {
return (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: c.status.success,
animation: 'ba-feed-pulse 1.4s ease-in-out infinite',
'@keyframes ba-feed-pulse': {
'0%, 100%': { opacity: 0.3, transform: 'scale(0.8)' },
'50%': { opacity: 1, transform: 'scale(1.2)' },
},
}}
/>
);
}
if (status === 'completed') {
return <CheckCircleOutlineIcon sx={{ fontSize: 10, color: c.status.success }} />;
}
if (status === 'error') {
return <ErrorOutlineIcon sx={{ fontSize: 10, color: c.status.error }} />;
}
return null;
};
export default React.memo(BrowserAgentInlineFeed);