From c730028874e47ea06b92d72f20f4db22fbca6731 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 10 Sep 2026 16:30:19 -0700 Subject: [PATCH 1/3] improvement(chat): show browser agent site favicons --- .../agent-group/agent-group-view.tsx | 15 +- .../agent-group/browser-agent-icon.test.tsx | 214 ++++++++++++++++++ .../agent-group/browser-agent-icon.tsx | 114 ++++++++++ 3 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx 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..ff3b82ec6dc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx @@ -0,0 +1,214 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' +import { + BrowserAgentIcon, + getBrowserAgentHostname, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon' +import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' + +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('getBrowserAgentHostname', () => { + 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(getBrowserAgentHostname([tool(), navigating])).toBe('example.org') + const redirected = tool({ params: { url: 'https://example.org/start' } }) + expect(getBrowserAgentHostname([redirected])).toBe('example.com') + expect( + getBrowserAgentHostname([ + redirected, + tool({ toolName: 'browser_type', status: 'executing', result: undefined }), + ]) + ).toBe('example.com') + }) + + it.each(['error', 'cancelled', 'rejected', 'awaiting_approval'] as const)( + 'does not adopt a destination from a %s tool', + (status) => { + expect( + getBrowserAgentHostname([ + tool(), + tool({ status, params: { url: 'https://example.org' }, result: undefined }), + ]) + ).toBe('example.com') + } + ) + + it.each(['', 'about:blank', 'file:///document', 'data:text/html,hello', 'not a URL'])( + 'clears the previous site for a page without an HTTP hostname: %s', + (url) => { + expect( + getBrowserAgentHostname([ + 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( + getBrowserAgentHostname([ + 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( + getBrowserAgentHostname([ + tool({ toolName: 'browser_list_tabs', result: { success: true, output } }), + ]) + ).toBe('example.com') + } + expect( + getBrowserAgentHostname([ + 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( + getBrowserAgentHostname([tool(), tool({ toolName, result: { success: true, output } })]) + ).toBe('example.org') + } + }) + + it('clears a stale site when an action navigated without reporting its destination', () => { + expect( + getBrowserAgentHostname([ + tool(), + tool({ + toolName: 'browser_click', + result: { success: true, output: { effect: { urlChanged: true } } }, + }), + ]) + ).toBeNull() + }) + + it('ignores URLs from other tools and nested agent runs', () => { + expect( + getBrowserAgentHostname([ + 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('example.com') + }) +}) + +describe('BrowserAgentIcon', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + root = createRoot(container) + }) + + afterEach(() => act(() => root.unmount())) + + const render = (url: string) => { + act(() => + root.render( + + ) + ) + } + + it('keeps the globe until load and resets image state when the hostname changes', () => { + render('https://example.com/document?token=private#section') + const firstImage = container.querySelector('img')! + expect(firstImage.src).toBe('https://www.google.com/s2/favicons?domain=example.com&sz=32') + 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() + + 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() + + 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..1e1aaa0627c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx @@ -0,0 +1,114 @@ +'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 { browserTabHostname } from '@/lib/browser-agent/tab-label' +import { faviconUrl } from '@/lib/core/utils/favicon' +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' + +/** Uses this run's page observations so historical icons never follow another run's live tab. */ +export function getBrowserAgentHostname(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' ? browserTabHostname(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 browserTabHostname(output.activeTab.url) + } + if (typeof output.url === 'string') return browserTabHostname(output.url) + if ( + toolName === 'browser_extract' && + isRecordLike(output.page) && + typeof output.page.url === 'string' + ) { + return browserTabHostname(output.page.url) + } + if ( + toolName === 'browser_screenshot' && + isRecordLike(output.viewport) && + typeof output.viewport.url === 'string' + ) { + return browserTabHostname(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' ? browserTabHostname(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 hostname = getBrowserAgentHostname(items) + return +} + +interface BrowserAgentFaviconProps { + hostname: string | null +} + +function BrowserAgentFavicon({ hostname }: BrowserAgentFaviconProps) { + const [status, setStatus] = useState<'loading' | 'loaded' | 'failed'>('loading') + + return ( + + ) +} From adba14cb373f7acaf3150328fca373bea1b0eb95 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 10 Sep 2026 16:38:19 -0700 Subject: [PATCH 2/3] fix(chat): load favicons from the visited site --- .../agent-group/browser-agent-icon.test.tsx | 82 ++++++++++++------- .../agent-group/browser-agent-icon.tsx | 44 ++++++---- 2 files changed, 77 insertions(+), 49 deletions(-) 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 index ff3b82ec6dc..c3cf90e04ea 100644 --- 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 @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' import { BrowserAgentIcon, - getBrowserAgentHostname, + getBrowserAgentFaviconUrl, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon' import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' @@ -25,53 +25,72 @@ function tool(overrides: Partial = {}): AgentGroupItem { } } -describe('getBrowserAgentHostname', () => { +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(getBrowserAgentHostname([tool(), navigating])).toBe('example.org') + expect(getBrowserAgentFaviconUrl([tool(), navigating])).toBe('https://example.org/favicon.ico') const redirected = tool({ params: { url: 'https://example.org/start' } }) - expect(getBrowserAgentHostname([redirected])).toBe('example.com') + expect(getBrowserAgentFaviconUrl([redirected])).toBe('https://example.com/favicon.ico') expect( - getBrowserAgentHostname([ + getBrowserAgentFaviconUrl([ redirected, tool({ toolName: 'browser_type', status: 'executing', result: undefined }), ]) - ).toBe('example.com') + ).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( - getBrowserAgentHostname([ + getBrowserAgentFaviconUrl([ tool(), tool({ status, params: { url: 'https://example.org' }, result: undefined }), ]) - ).toBe('example.com') + ).toBe('https://example.com/favicon.ico') } ) - it.each(['', 'about:blank', 'file:///document', 'data:text/html,hello', 'not a URL'])( - 'clears the previous site for a page without an HTTP hostname: %s', - (url) => { - expect( - getBrowserAgentHostname([ - tool(), - tool({ toolName: 'browser_switch_tab', result: { success: true, output: { url } } }), - ]) - ).toBeNull() - } - ) + 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( - getBrowserAgentHostname([ + getBrowserAgentFaviconUrl([ tool(), tool({ toolName, status: 'executing', result: undefined }), ]) @@ -89,13 +108,13 @@ describe('getBrowserAgentHostname', () => { { tabs, activeTabId: 'agent' }, ]) { expect( - getBrowserAgentHostname([ + getBrowserAgentFaviconUrl([ tool({ toolName: 'browser_list_tabs', result: { success: true, output } }), ]) - ).toBe('example.com') + ).toBe('https://example.com/favicon.ico') } expect( - getBrowserAgentHostname([ + getBrowserAgentFaviconUrl([ tool(), tool({ toolName: 'browser_list_tabs', @@ -118,14 +137,14 @@ describe('getBrowserAgentHostname', () => { ['browser_extract', { page: { url: 'https://example.org' } }], ] as const) { expect( - getBrowserAgentHostname([tool(), tool({ toolName, result: { success: true, output } })]) - ).toBe('example.org') + 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( - getBrowserAgentHostname([ + getBrowserAgentFaviconUrl([ tool(), tool({ toolName: 'browser_click', @@ -137,7 +156,7 @@ describe('getBrowserAgentHostname', () => { it('ignores URLs from other tools and nested agent runs', () => { expect( - getBrowserAgentHostname([ + getBrowserAgentFaviconUrl([ tool(), tool({ toolName: 'browser_read_text', @@ -160,7 +179,7 @@ describe('getBrowserAgentHostname', () => { }, }, ]) - ).toBe('example.com') + ).toBe('https://example.com/favicon.ico') }) }) @@ -184,10 +203,11 @@ describe('BrowserAgentIcon', () => { ) } - it('keeps the globe until load and resets image state when the hostname changes', () => { - render('https://example.com/document?token=private#section') + it('keeps the globe until load and resets image state when the page origin changes', () => { + render('https://username:password@example.com/document?token=private#section') const firstImage = container.querySelector('img')! - expect(firstImage.src).toBe('https://www.google.com/s2/favicons?domain=example.com&sz=32') + 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() 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 index 1e1aaa0627c..4d4b9b5c6ad 100644 --- 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 @@ -5,13 +5,22 @@ import { isBrowserToolName } from '@sim/browser-protocol' import { cn } from '@sim/emcn' import { Globe } from '@sim/emcn/icons' import { isRecordLike } from '@sim/utils/object' -import { browserTabHostname } from '@/lib/browser-agent/tab-label' -import { faviconUrl } from '@/lib/core/utils/favicon' 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' +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 getBrowserAgentHostname(items: AgentGroupItem[]): string | null { +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 @@ -25,33 +34,33 @@ export function getBrowserAgentHostname(items: AgentGroupItem[]): string | null toolName === 'browser_open_url' || toolName === 'browser_open_tab' if (status === ToolCallStatus.executing && navigatesToUrl) { - return typeof params?.url === 'string' ? browserTabHostname(params.url) : null + 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 browserTabHostname(output.activeTab.url) + return pageFaviconUrl(output.activeTab.url) } - if (typeof output.url === 'string') return browserTabHostname(output.url) + if (typeof output.url === 'string') return pageFaviconUrl(output.url) if ( toolName === 'browser_extract' && isRecordLike(output.page) && typeof output.page.url === 'string' ) { - return browserTabHostname(output.page.url) + return pageFaviconUrl(output.page.url) } if ( toolName === 'browser_screenshot' && isRecordLike(output.viewport) && typeof output.viewport.url === 'string' ) { - return browserTabHostname(output.viewport.url) + 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' ? browserTabHostname(tab.url) : null + return isRecordLike(tab) && typeof tab.url === 'string' ? pageFaviconUrl(tab.url) : null } /** A navigation without a destination invalidates the previous page observation. */ if ( @@ -81,22 +90,23 @@ interface BrowserAgentIconProps { } export function BrowserAgentIcon({ items }: BrowserAgentIconProps) { - const hostname = getBrowserAgentHostname(items) - return + const url = getBrowserAgentFaviconUrl(items) + return } interface BrowserAgentFaviconProps { - hostname: string | null + url: string | null } -function BrowserAgentFavicon({ hostname }: BrowserAgentFaviconProps) { +function BrowserAgentFavicon({ url }: BrowserAgentFaviconProps) { const [status, setStatus] = useState<'loading' | 'loaded' | 'failed'>('loading') return ( ) } From d228a18b4a095a3ecf812a4dc4fe76c6ba0e892b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 10 Sep 2026 16:55:47 -0700 Subject: [PATCH 3/3] fix(chat): limit favicon loads to the local agent browser --- .../agent-group/browser-agent-icon.test.tsx | 66 ++++++++++++++++++- .../agent-group/browser-agent-icon.tsx | 24 ++++++- 2 files changed, 86 insertions(+), 4 deletions(-) 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 index c3cf90e04ea..70b38e2bf03 100644 --- 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 @@ -3,13 +3,34 @@ */ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +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 { @@ -189,6 +210,9 @@ describe('BrowserAgentIcon', () => { 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) }) @@ -203,7 +227,45 @@ describe('BrowserAgentIcon', () => { ) } + 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') @@ -216,6 +278,7 @@ describe('BrowserAgentIcon', () => { 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() @@ -225,6 +288,7 @@ describe('BrowserAgentIcon', () => { 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') 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 index 4d4b9b5c6ad..bc698497999 100644 --- 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 @@ -5,8 +5,11 @@ 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 { @@ -90,20 +93,35 @@ interface BrowserAgentIconProps { } export function BrowserAgentIcon({ items }: BrowserAgentIconProps) { + const { chatId } = useChatSurface() const url = getBrowserAgentFaviconUrl(items) - return + 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 }: BrowserAgentFaviconProps) { +function BrowserAgentFavicon({ url, canLoad }: BrowserAgentFaviconProps) { const [status, setStatus] = useState<'loading' | 'loaded' | 'failed'>('loading') return (