From c0f089b7414bcb4db172aad551f8e091526cdfde Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 21 Jul 2026 18:40:19 -0700 Subject: [PATCH] [eric] chat: web tools read as sources (favicon stacks + domains at the shared summary choke point), perplexity treatment --- .../AgentChat/parsing/toolResultParsing.ts | 9 +- .../tool-bubbles/CompactMcpBubble.tsx | 46 ++++++---- .../tool-bubbles/DefaultToolBubble.tsx | 43 ++++++--- .../AgentChat/tool-bubbles/SourceFavicons.tsx | 90 +++++++++++++++++++ .../tool-bubbles/ToolGroupBubble.tsx | 44 +++++++-- frontend/src/shared/mcpToolMeta.ts | 14 +++ 6 files changed, 203 insertions(+), 43 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/tool-bubbles/SourceFavicons.tsx diff --git a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts index 102c23e7..a49bf603 100644 --- a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts +++ b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts @@ -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)) { diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx index 4f713a79..0243e622 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx @@ -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 = ({ })(); 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 = ({ )} {visibleSummary && !isError && !stackBelow && ( - - {visibleSummary} - + <> + {webDomain && ( + ) => { e.currentTarget.style.display = 'none'; }} + sx={{ width: 13, height: 13, borderRadius: '3px', flexShrink: 0 }} + /> + )} + + {visibleSummary} + + )} {(stackBelow || !visibleSummary) && !showTimer && } {showTimer && ( diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx index ac477e69..7a5aaebb 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx @@ -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 = ({ 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 ( = ({ }`, 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 = ({ )} {inputSummary && !isStreaming && ( - - {inputSummary} - + + {webDomain && ( + ) => { e.currentTarget.style.display = 'none'; }} + sx={{ width: 13, height: 13, borderRadius: '3px', flexShrink: 0 }} + /> + )} + + {inputSummary} + + )} {!inputSummary && } {isStreaming && } diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/SourceFavicons.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/SourceFavicons.tsx new file mode 100644 index 00000000..8574aaf5 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/SourceFavicons.tsx @@ -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 ( + + {(domain[0] || '?').toUpperCase()} + + ); + } + return ( + 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 ( + + {shown.map((d, i) => ( + + ))} + {extra > 0 && ( + + +{extra} + + )} + + ); +}; diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx index 84e91dc3..2a72de4c 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx @@ -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 = 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 ( = 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 && } - {group.callCount} tool call{group.callCount === 1 ? '' : 's'} + {webGroupLabel ?? `${group.callCount} tool call${group.callCount === 1 ? '' : 's'}`} {!allDone && ( {completedCount}/{group.callCount} )} + {allDone && webDomains && webDomains.length > 0 && ( + + {webDomains.length} source{webDomains.length === 1 ? '' : 's'} + + )} {deniedCount > 0 && ( {deniedCount} denied @@ -176,7 +199,9 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = '&:hover': { bgcolor: 'rgba(0,0,0,0.02)' }, }} > - {!meta ? ( + {webDomains && webDomains.length > 0 ? ( + + ) : !meta ? ( ) : hasSvg ? ( @@ -184,7 +209,7 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = )} - {!meta ? ( + {!meta && !webGroupLabel && !workflowGroupLabel ? ( @@ -231,8 +256,13 @@ const ToolGroupBubble: React.FC = 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)' }, diff --git a/frontend/src/shared/mcpToolMeta.ts b/frontend/src/shared/mcpToolMeta.ts index f5ecef13..00cf2d93 100644 --- a/frontend/src/shared/mcpToolMeta.ts +++ b/frontend/src/shared/mcpToolMeta.ts @@ -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;