[eric] chat: web tools read as sources (favicon stacks + domains at the shared summary choke point), perplexity treatment

This commit is contained in:
ciregenz
2026-07-21 18:40:19 -07:00
parent 7c86293b72
commit c0f089b741
6 changed files with 203 additions and 43 deletions
@@ -24,14 +24,7 @@ export function getInputSummary(toolName: string, input: any): string {
if (isSettingsReadTool(toolName)) return '';
const mcp = parseMcpToolName(toolName);
if (mcp.isMcp) {
// Web tools (our openswarm-web MCP) get the clean source summary, a bare domain / quoted query,
// instead of the generic "url: https://… prompt: …" key-value dump. Reads like Perplexity sources.
const act = (mcp.action || '').toLowerCase();
if (act === 'websearch') return quoteQuery(input.query || input.search_term || '');
if (act === 'webfetch') return prettyUrl(input.url || '');
return getMcpInputSummary(input, mcp.action, mcp.serverSlug);
}
if (mcp.isMcp) return getMcpInputSummary(input, mcp.action, mcp.serverSlug);
const n = toolName.toLowerCase();
if (isBashTool(toolName)) {
@@ -18,6 +18,7 @@ import { ParsedResult } from '../parsing/toolResultParsing';
import { isSettingsWriteTool, settingsWriteSummary } from '../parsing/settingsToolMeta';
import { McpToolInfo, getMcpShortAction, getMcpInputSummary, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
import { McpResultCard } from '../mcp-cards/McpResultCard';
import { domainFromUrl, faviconUrlForDomain } from './SourceFavicons';
interface CompactMcpBubbleProps {
call: AgentMessage;
@@ -57,6 +58,9 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
})();
const serviceLabel = mcpInfo.isMcp ? mcpVerbLabel : shortAction;
const inputSummary = mcpInfo.isMcp ? getMcpInputSummary(input, mcpInfo.action, mcpInfo.serverSlug) : '';
// Web rows read as sources: favicon beside the domain/query, body text instead of mono.
const isWebRow = /web(fetch|search)$/i.test(toolName);
const webDomain = /webfetch$/i.test(toolName) && typeof input?.url === 'string' ? domainFromUrl(input.url) : '';
// A grouped settings write shows the masked change list (input-derived, so it reads even while pending) instead of the generic "Applied: theme" result line.
const visibleSummary = isSettingsWriteTool(toolName)
? settingsWriteSummary(input)
@@ -95,21 +99,33 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
</Typography>
)}
{visibleSummary && !isError && !stackBelow && (
<Typography
sx={{
color: hideVerbLabel ? c.text.primary : c.text.secondary,
fontSize: '0.74rem',
// Args are data (ids, URLs, params), so they read in mono, not the body serif.
fontFamily: c.font.mono,
flex: 1,
minWidth: 0,
...(showBody && canToggleDetails
? { whiteSpace: 'normal', overflowWrap: 'anywhere' }
: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
}}
>
{visibleSummary}
</Typography>
<>
{webDomain && (
<Box
component="img"
src={faviconUrlForDomain(webDomain)}
alt=""
loading="lazy"
onError={(e: React.SyntheticEvent<HTMLImageElement>) => { e.currentTarget.style.display = 'none'; }}
sx={{ width: 13, height: 13, borderRadius: '3px', flexShrink: 0 }}
/>
)}
<Typography
sx={{
color: hideVerbLabel ? c.text.primary : c.text.secondary,
fontSize: '0.74rem',
// Args are data (ids, URLs, params) and read in mono; web rows are SOURCES and read in body text.
fontFamily: isWebRow ? undefined : c.font.mono,
flex: 1,
minWidth: 0,
...(showBody && canToggleDetails
? { whiteSpace: 'normal', overflowWrap: 'anywhere' }
: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
}}
>
{visibleSummary}
</Typography>
</>
)}
{(stackBelow || !visibleSummary) && !showTimer && <Box sx={{ flex: 1 }} />}
{showTimer && (
@@ -20,6 +20,7 @@ import { useTermColors, colorizeInput, colorizeOutput } from '../parsing/toolCol
import { ParsedResult } from '../parsing/toolResultParsing';
import { McpToolInfo } from '@/shared/mcpToolMeta';
import { McpResultCard } from '../mcp-cards/McpResultCard';
import { domainFromUrl, faviconUrlForDomain } from './SourceFavicons';
interface DefaultToolBubbleProps {
call: AgentMessage;
@@ -59,6 +60,10 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
const reveal = useMountReveal();
const enterStyle = (!mcpCompact && !suppressReveal) ? reveal : {};
const canToggleDetails = !!inputSummary && !isStreaming;
// A web read shows its SOURCE (favicon + domain), not a url dump; the Perplexity treatment.
const webDomain = /webfetch$/i.test(toolName) && typeof input?.url === 'string'
? domainFromUrl(input.url)
: '';
return (
<Box
@@ -82,7 +87,8 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
}`,
borderRadius: mcpCompact ? 0 : 2,
overflow: 'hidden',
animation: (isPending || isStreaming) && !mcpCompact ? 'border-glow 2s ease-in-out infinite' : 'none',
// Live state stays calm: the accent border + the ElapsedTimer's small pulsing dot carry
// "working"; the old whole-bubble box-shadow glow loop read as noise (animation-purge rule).
transition: 'border-color 0.3s, box-shadow 0.3s',
} as any}
>
@@ -134,18 +140,29 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
</Typography>
)}
{inputSummary && !isStreaming && (
<Typography
noWrap
sx={{
color: c.text.tertiary,
fontSize: '0.75rem',
fontFamily: c.font.mono,
flex: 1,
minWidth: 0,
}}
>
{inputSummary}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flex: 1, minWidth: 0 }}>
{webDomain && (
<Box
component="img"
src={faviconUrlForDomain(webDomain)}
alt=""
loading="lazy"
onError={(e: React.SyntheticEvent<HTMLImageElement>) => { e.currentTarget.style.display = 'none'; }}
sx={{ width: 13, height: 13, borderRadius: '3px', flexShrink: 0 }}
/>
)}
<Typography
noWrap
sx={{
color: c.text.tertiary,
fontSize: '0.75rem',
fontFamily: webDomain ? undefined : c.font.mono,
minWidth: 0,
}}
>
{inputSummary}
</Typography>
</Box>
)}
{!inputSummary && <Box sx={{ flex: 1 }} />}
{isStreaming && <Box sx={{ flex: 1 }} />}
@@ -0,0 +1,90 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// Same favicon service LibreChat ships with; the browser cards already load arbitrary sites, so
// fetching site icons adds no new exposure class.
export function faviconUrlForDomain(domain: string): string {
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
}
export function domainFromUrl(url: string): string {
try {
return new URL(url).host.replace(/^www\./, '');
} catch {
return url.replace(/^https?:\/\//, '').split(/[/?#]/)[0];
}
}
const MAX_STACK = 3;
const FaviconDot: React.FC<{ domain: string; size: number; overlap: boolean; z: number }> = ({ domain, size, overlap, z }) => {
const c = useClaudeTokens();
const [failed, setFailed] = useState(false);
const ring = {
width: size,
height: size,
borderRadius: '50%',
border: `1.5px solid ${c.bg.elevated}`,
bgcolor: c.bg.secondary,
ml: overlap ? '-6px' : 0,
zIndex: z,
position: 'relative' as const,
flexShrink: 0,
};
if (failed) {
// assistant-ui's fallback: the domain's first letter beats a hole in the stack.
return (
<Box sx={{ ...ring, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: c.text.tertiary, fontSize: size * 0.55, fontWeight: 700 }}>
{(domain[0] || '?').toUpperCase()}
</Box>
);
}
return (
<Box
component="img"
src={faviconUrlForDomain(domain)}
alt=""
loading="lazy"
onError={() => setFailed(true)}
sx={ring}
/>
);
};
/** Perplexity-style overlapping favicon stack for web-source rows. */
export const SourceFavicons: React.FC<{ domains: string[]; size?: number }> = ({ domains, size = 16 }) => {
const c = useClaudeTokens();
if (domains.length === 0) return null;
const shown = domains.slice(0, MAX_STACK);
const extra = domains.length - shown.length;
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>
{shown.map((d, i) => (
<FaviconDot key={d} domain={d} size={size} overlap={i !== 0} z={MAX_STACK - i} />
))}
{extra > 0 && (
<Box
sx={{
ml: '-6px',
minWidth: size,
height: size,
px: 0.4,
borderRadius: 999,
border: `1.5px solid ${c.bg.elevated}`,
bgcolor: c.bg.secondary,
color: c.text.tertiary,
fontSize: '0.6rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
>
+{extra}
</Box>
)}
</Box>
);
};
@@ -12,6 +12,7 @@ import { useMountReveal } from './useMountReveal';
import { sanitizeSvgString } from '@/shared/sanitizeSvg';
import { parseMcpToolName, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
import ToolCallBubble, { ToolPair } from './ToolCallBubble';
import { SourceFavicons, domainFromUrl } from './SourceFavicons';
export type ToolGroupEntry =
| { kind: 'pair'; pair: ToolPair }
@@ -107,8 +108,24 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
}).filter(Boolean))) as string[];
return parsedLabels.length === 1 ? parsedLabels[0] : 'Workflow actions';
})();
const displayName = workflowGroupLabel || meta?.name || group.label;
const hasSvg = !!meta?.svg && !workflowGroupLabel;
// A web group is a SEARCH, so it wears its sources: favicon stack + "Searched the web", the
// Perplexity read (same special-case precedent as openswarm-schedule above).
const webDomains = useMemo(() => {
if (group.mcpServer !== 'openswarm-web') return null;
const domains: string[] = [];
for (const p of group.pairs) {
const cc = typeof p.call.content === 'object' ? p.call.content : {};
const url = (cc.input as { url?: unknown } | undefined)?.url;
if (typeof url === 'string' && url) {
const d = domainFromUrl(url);
if (d && !domains.includes(d)) domains.push(d);
}
}
return domains;
}, [group]);
const webGroupLabel = webDomains ? 'Searched the web' : null;
const displayName = workflowGroupLabel || webGroupLabel || meta?.name || group.label;
const hasSvg = !!meta?.svg && !workflowGroupLabel && !webGroupLabel;
return (
<Box
@@ -141,21 +158,27 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
gap: 0.75,
py: 0.4,
cursor: 'pointer',
color: c.text.tertiary,
'&:hover': { color: c.text.secondary },
}}
>
{webDomains && webDomains.length > 0 && <SourceFavicons domains={webDomains} size={16} />}
<Typography sx={{ fontSize: '0.8rem', fontWeight: 500, color: 'inherit' }}>
{group.callCount} tool call{group.callCount === 1 ? '' : 's'}
{webGroupLabel ?? `${group.callCount} tool call${group.callCount === 1 ? '' : 's'}`}
</Typography>
{!allDone && (
<Typography sx={{ fontSize: '0.7rem', color: 'inherit', fontFamily: c.font.mono, fontVariantNumeric: 'tabular-nums' }}>
{completedCount}/{group.callCount}
</Typography>
)}
{allDone && webDomains && webDomains.length > 0 && (
<Typography sx={{ fontSize: '0.7rem', color: 'inherit', fontVariantNumeric: 'tabular-nums' }}>
{webDomains.length} source{webDomains.length === 1 ? '' : 's'}
</Typography>
)}
{deniedCount > 0 && (
<Typography sx={{ color: c.status.error, fontSize: '0.68rem' }}>
{deniedCount} denied
@@ -176,7 +199,9 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
'&:hover': { bgcolor: 'rgba(0,0,0,0.02)' },
}}
>
{!meta ? (
{webDomains && webDomains.length > 0 ? (
<SourceFavicons domains={webDomains} size={16} />
) : !meta ? (
<SkeletonPulse width={15} height={15} borderRadius={8} />
) : hasSvg ? (
<GeneratedSvgIcon svg={meta.svg} size={15} color={c.accent.primary} />
@@ -184,7 +209,7 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
<TerminalIcon sx={{ fontSize: 15, color: c.accent.primary, flexShrink: 0 }} />
)}
{!meta ? (
{!meta && !webGroupLabel && !workflowGroupLabel ? (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<SkeletonPulse width={100} height={12} />
</Box>
@@ -231,8 +256,13 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
sx={{
borderTop: `0.5px solid ${c.border.medium}`,
'& > *': {
animation: 'toolRowFadeIn 140ms ease-out',
animation: 'toolRowFadeIn 140ms ease-out backwards',
},
// Staggered entrance (assistant-ui's tool-group treatment): rows cascade instead of popping at once.
'& > *:nth-of-type(2)': { animationDelay: '40ms' },
'& > *:nth-of-type(3)': { animationDelay: '80ms' },
'& > *:nth-of-type(4)': { animationDelay: '120ms' },
'& > *:nth-of-type(n+5)': { animationDelay: '160ms' },
'@keyframes toolRowFadeIn': {
from: { opacity: 0, transform: 'translateY(-2px)' },
to: { opacity: 1, transform: 'translateY(0)' },
+14
View File
@@ -166,6 +166,20 @@ function p_cleanSummaryText(s: string): string {
export function getMcpInputSummary(input: any, action?: string, serverSlug?: string): string {
if (!input || typeof input !== 'object') return '';
// Web tools read as SOURCES, not payload dumps: a quoted query for a search, a bare domain for a
// page read. Lives here so every caller (compact rows, labels, approvals) gets the clean form.
const p_act = (action || '').toLowerCase();
if (p_act === 'websearch') {
const q = input.query ?? input.search_term;
if (typeof q === 'string' && q.trim()) return `"${p_cleanSummaryText(q.trim())}"`;
}
if (p_act === 'webfetch' && typeof input.url === 'string' && input.url) {
try {
return new URL(input.url).host.replace(/^www\./, '');
} catch {
return input.url.replace(/^https?:\/\//, '').split(/[/?#]/)[0];
}
}
if (serverSlug === 'openswarm-schedule' || (action && getWorkflowToolLabel(action))) {
const workflowSummary = compactWorkflowSchedule(input);
if (workflowSummary) return workflowSummary;