diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx index 18b3e767a9a..0b8d1f3886e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx @@ -13,6 +13,7 @@ import { ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@s import { ShimmerText } from '@/components/ui' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' +import { BrowserAgentIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon' import { renderInlineMarkdown } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown' import { getVisibleMainAgentItems } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity' import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' @@ -157,6 +158,12 @@ export function AgentGroupView({ renderBrowserTakeover, }: AgentGroupViewProps) { const AgentIcon = getAgentIcon(agentName) + const agentIcon = + agentName === 'browser' ? ( + + ) : ( + + ) const isMainAgent = agentName === 'mothership' // Collapsed status line: the latest tool call, always in its RUNNING // phrasing — it never flips to the completed rewrite (that lives in the @@ -261,9 +268,7 @@ export function AgentGroupView({ onClick={toggleExpanded} className='group/agent flex w-full min-w-0 cursor-pointer items-center gap-2 text-left' > -
- -
+
{agentIcon}
{isWorking ? ( {headerText} ) : ( @@ -278,9 +283,7 @@ export function AgentGroupView({ ) : (
-
- -
+
{agentIcon}
{isWorking ? ( {headerText} ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx new file mode 100644 index 00000000000..70b38e2bf03 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx @@ -0,0 +1,298 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' +import { + BrowserAgentIcon, + getBrowserAgentFaviconUrl, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon' +import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +const { browserAvailable, chatIdentity } = vi.hoisted(() => ({ + browserAvailable: vi.fn(() => true), + chatIdentity: { chatId: 'chat-1' }, +})) +vi.mock('@/lib/browser-agent/transport', () => ({ isBrowserAgentAvailable: browserAvailable })) +vi.mock('@/app/workspace/[workspaceId]/home/components/chat-surface-context', () => ({ + useChatSurface: () => chatIdentity, +})) + +function openPage(url: string, scopeId = 'chat-1', loading = false) { + act(() => + useBrowserSessionStore.getState().setTabsState({ + scopeId, + activeTabId: 'tab-1', + automationTabId: 'tab-1', + tabs: [{ tabId: 'tab-1', title: '', url, loading, active: true }], + }) + ) +} + +function tool(overrides: Partial = {}): AgentGroupItem { + return { + type: 'tool', + data: { + id: 'browser-tool', + toolName: 'browser_navigate', + displayTitle: 'Opening page', + status: 'success', + result: { success: true, output: { url: 'https://example.com/document' } }, + ...overrides, + }, + } +} + +describe('getBrowserAgentFaviconUrl', () => { + it('keeps the observed origin and port without disclosing page details', () => { + expect( + getBrowserAgentFaviconUrl([ + tool({ + result: { + success: true, + output: { + url: 'https://username:password@example.com:8443/document?token=private#part', + }, + }, + }), + ]) + ).toBe('https://example.com:8443/favicon.ico') + }) + + it('uses the pending destination, then the observed redirect, and retains it during editing', () => { + const navigating = tool({ + status: 'executing', + params: { url: 'https://example.org/start' }, + result: undefined, + }) + expect(getBrowserAgentFaviconUrl([tool(), navigating])).toBe('https://example.org/favicon.ico') + const redirected = tool({ params: { url: 'https://example.org/start' } }) + expect(getBrowserAgentFaviconUrl([redirected])).toBe('https://example.com/favicon.ico') + expect( + getBrowserAgentFaviconUrl([ + redirected, + tool({ toolName: 'browser_type', status: 'executing', result: undefined }), + ]) + ).toBe('https://example.com/favicon.ico') + }) + + it.each(['error', 'cancelled', 'rejected', 'awaiting_approval'] as const)( + 'does not adopt a destination from a %s tool', + (status) => { + expect( + getBrowserAgentFaviconUrl([ + tool(), + tool({ status, params: { url: 'https://example.org' }, result: undefined }), + ]) + ).toBe('https://example.com/favicon.ico') + } + ) + + it.each([ + '', + 'about:blank', + 'http://example.com', + 'file:///document', + 'data:text/html,hello', + 'not a URL', + ])('clears the previous site when the page cannot supply an allowed favicon: %s', (url) => { + expect( + getBrowserAgentFaviconUrl([ + tool(), + tool({ toolName: 'browser_switch_tab', result: { success: true, output: { url } } }), + ]) + ).toBeNull() + }) + + it.each(['browser_open_tab', 'browser_switch_tab', 'browser_close_tab', 'browser_go_back'])( + 'clears an obsolete site while %s has no known destination', + (toolName) => { + expect( + getBrowserAgentFaviconUrl([ + tool(), + tool({ toolName, status: 'executing', result: undefined }), + ]) + ).toBeNull() + } + ) + + it('uses the agent tab from a tab list, including legacy results', () => { + const tabs = [ + { tabId: 'visible', url: 'https://example.org' }, + { tabId: 'agent', url: 'https://example.com' }, + ] + for (const output of [ + { tabs, activeTabId: 'visible', automationTabId: 'agent' }, + { tabs, activeTabId: 'agent' }, + ]) { + expect( + getBrowserAgentFaviconUrl([ + tool({ toolName: 'browser_list_tabs', result: { success: true, output } }), + ]) + ).toBe('https://example.com/favicon.ico') + } + expect( + getBrowserAgentFaviconUrl([ + tool(), + tool({ + toolName: 'browser_list_tabs', + result: { + success: true, + output: { tabs, activeTabId: 'visible', automationTabId: null }, + }, + }), + ]) + ).toBeNull() + }) + + it('follows a click into a new tab and screenshot page metadata', () => { + for (const [toolName, output] of [ + [ + 'browser_click', + { activeTab: { url: 'https://example.org' }, effect: { tabChanged: true } }, + ], + ['browser_screenshot', { viewport: { url: 'https://example.org' } }], + ['browser_extract', { page: { url: 'https://example.org' } }], + ] as const) { + expect( + getBrowserAgentFaviconUrl([tool(), tool({ toolName, result: { success: true, output } })]) + ).toBe('https://example.org/favicon.ico') + } + }) + + it('clears a stale site when an action navigated without reporting its destination', () => { + expect( + getBrowserAgentFaviconUrl([ + tool(), + tool({ + toolName: 'browser_click', + result: { success: true, output: { effect: { urlChanged: true } } }, + }), + ]) + ).toBeNull() + }) + + it('ignores URLs from other tools and nested agent runs', () => { + expect( + getBrowserAgentFaviconUrl([ + tool(), + tool({ + toolName: 'browser_read_text', + params: { elementId: 4 }, + result: { success: true, output: { url: 'https://example.org/embedded' } }, + }), + tool({ + toolName: 'web_search', + result: { success: true, output: { url: 'https://example.org' } }, + }), + { + type: 'agent_group', + group: { + id: 'other-run', + agentName: 'browser', + agentLabel: 'Browser', + isOpen: true, + isDelegating: true, + items: [tool({ result: { success: true, output: { url: 'https://example.org' } } })], + }, + }, + ]) + ).toBe('https://example.com/favicon.ico') + }) +}) + +describe('BrowserAgentIcon', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + browserAvailable.mockReturnValue(true) + chatIdentity.chatId = 'chat-1' + useBrowserSessionStore.setState({ sessions: {}, activeScopeId: null }) + container = document.createElement('div') + root = createRoot(container) + }) + + afterEach(() => act(() => root.unmount())) + + const render = (url: string) => { + act(() => + root.render( + + ) + ) + } + + it('does not contact sites from history, other chats, or pending navigation', () => { + render('https://example.com/document') + expect(container.querySelector('img')).toBeNull() + openPage('https://example.com', 'another-chat') + expect(container.querySelector('img')).toBeNull() + openPage('https://example.com', 'chat-1', true) + expect(container.querySelector('img')).toBeNull() + openPage('https://example.org') + expect(container.querySelector('img')).toBeNull() + openPage('https://example.com') + expect(container.querySelector('img')).not.toBeNull() + }) + + it('requires the local desktop browser and retains an already loaded favicon after closing it', () => { + browserAvailable.mockReturnValue(false) + openPage('https://example.com') + render('https://example.com') + expect(container.querySelector('img')).toBeNull() + browserAvailable.mockReturnValue(true) + render('https://example.com') + const img = container.querySelector('img')! + act(() => img.dispatchEvent(new Event('load'))) + act(() => useBrowserSessionStore.getState().discardScope('chat-1')) + expect(container.querySelector('img')).toBe(img) + expect(container.querySelector('svg')).toBeNull() + }) + + it('does not carry loaded state into another chat with the same destination', () => { + openPage('https://example.com') + render('https://example.com') + act(() => container.querySelector('img')!.dispatchEvent(new Event('load'))) + chatIdentity.chatId = 'chat-2' + render('https://example.com') + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('svg')).not.toBeNull() + }) + + it('keeps the globe until load and resets image state when the page origin changes', () => { + openPage('https://example.com/document') + render('https://username:password@example.com/document?token=private#section') + const firstImage = container.querySelector('img')! + expect(firstImage.src).toBe('https://example.com/favicon.ico') + expect(firstImage.getAttribute('referrerpolicy')).toBe('no-referrer') + expect(container.querySelector('svg')).not.toBeNull() + act(() => firstImage.dispatchEvent(new Event('load'))) + expect(container.querySelector('svg')).toBeNull() + + render('https://example.com/another-document') + expect(container.querySelector('img')).toBe(firstImage) + expect(container.querySelector('svg')).toBeNull() + + openPage('https://example.org/document') + render('https://example.org/document') + expect(container.querySelector('img')).not.toBe(firstImage) + expect(container.querySelector('svg')).not.toBeNull() + act(() => firstImage.dispatchEvent(new Event('error'))) + expect(container.querySelector('img')).not.toBeNull() + act(() => container.querySelector('img')!.dispatchEvent(new Event('error'))) + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('svg')).not.toBeNull() + + openPage('https://example.com') + render('https://example.com') + expect(container.querySelector('img')).not.toBeNull() + render('about:blank') + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('svg')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx new file mode 100644 index 00000000000..bc698497999 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useState } from 'react' +import { isBrowserToolName } from '@sim/browser-protocol' +import { cn } from '@sim/emcn' +import { Globe } from '@sim/emcn/icons' +import { isRecordLike } from '@sim/utils/object' +import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' +import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' +import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' +import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +function pageFaviconUrl(url: string): string | null { + try { + const page = new URL(url) + /** External images must use HTTPS under the app's content security policy. */ + if (page.protocol !== 'https:') return null + return `${page.origin}/favicon.ico` + } catch { + return null + } +} + +/** Uses this run's page observations so historical icons never follow another run's live tab. */ +export function getBrowserAgentFaviconUrl(items: AgentGroupItem[]): string | null { + for (let index = items.length - 1; index >= 0; index--) { + const item = items[index] + if (item.type !== 'tool' || !isBrowserToolName(item.data.toolName)) continue + const { toolName, status, params, result } = item.data + if (status !== ToolCallStatus.executing && status !== ToolCallStatus.success) continue + /** Element-scoped reads can report an iframe URL rather than the browser page. */ + if (toolName === 'browser_read_text' && params?.elementId !== undefined) continue + + const navigatesToUrl = + toolName === 'browser_navigate' || + toolName === 'browser_open_url' || + toolName === 'browser_open_tab' + if (status === ToolCallStatus.executing && navigatesToUrl) { + return typeof params?.url === 'string' ? pageFaviconUrl(params.url) : null + } + + const output = result?.success && isRecordLike(result.output) ? result.output : null + if (output) { + if (isRecordLike(output.activeTab) && typeof output.activeTab.url === 'string') { + return pageFaviconUrl(output.activeTab.url) + } + if (typeof output.url === 'string') return pageFaviconUrl(output.url) + if ( + toolName === 'browser_extract' && + isRecordLike(output.page) && + typeof output.page.url === 'string' + ) { + return pageFaviconUrl(output.page.url) + } + if ( + toolName === 'browser_screenshot' && + isRecordLike(output.viewport) && + typeof output.viewport.url === 'string' + ) { + return pageFaviconUrl(output.viewport.url) + } + if (toolName === 'browser_list_tabs' && Array.isArray(output.tabs)) { + const tabId = 'automationTabId' in output ? output.automationTabId : output.activeTabId + const tab = output.tabs.find((tab) => isRecordLike(tab) && tab.tabId === tabId) + return isRecordLike(tab) && typeof tab.url === 'string' ? pageFaviconUrl(tab.url) : null + } + /** A navigation without a destination invalidates the previous page observation. */ + if ( + isRecordLike(output.effect) && + (output.effect.urlChanged === true || + output.effect.topUrlChanged === true || + output.effect.tabChanged === true) + ) { + return null + } + } + if ( + navigatesToUrl || + toolName === 'browser_switch_tab' || + toolName === 'browser_close_tab' || + toolName === 'browser_go_back' || + toolName === 'browser_go_forward' + ) { + return null + } + } + return null +} + +interface BrowserAgentIconProps { + items: AgentGroupItem[] +} + +export function BrowserAgentIcon({ items }: BrowserAgentIconProps) { + const { chatId } = useChatSurface() + const url = getBrowserAgentFaviconUrl(items) + const pageIsOpen = useBrowserSessionStore((state) => { + const session = chatId ? state.sessions[chatId] : undefined + if (!url || !session?.sessionAlive || session.suspended) return false + const tab = session.tabs.find((tab) => tab.tabId === session.automationTabId) + return Boolean(tab && !tab.loading && !tab.issue && pageFaviconUrl(tab.url) === url) + }) + return ( + + ) +} + +interface BrowserAgentFaviconProps { + url: string | null + canLoad: boolean +} + +function BrowserAgentFavicon({ url, canLoad }: BrowserAgentFaviconProps) { + const [status, setStatus] = useState<'loading' | 'loaded' | 'failed'>('loading') + + return ( + + ) +}