Skip to content

Commit bcce829

Browse files
authored
improvement(chat): show browser agent site favicons (#7747)
* improvement(chat): show browser agent site favicons * fix(chat): load favicons from the visited site * fix(chat): limit favicon loads to the local agent browser
1 parent 1a82a17 commit bcce829

3 files changed

Lines changed: 447 additions & 6 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@s
1313
import { ShimmerText } from '@/components/ui'
1414
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
1515
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
16+
import { BrowserAgentIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon'
1617
import { renderInlineMarkdown } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown'
1718
import { getVisibleMainAgentItems } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity'
1819
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
@@ -157,6 +158,12 @@ export function AgentGroupView({
157158
renderBrowserTakeover,
158159
}: AgentGroupViewProps) {
159160
const AgentIcon = getAgentIcon(agentName)
161+
const agentIcon =
162+
agentName === 'browser' ? (
163+
<BrowserAgentIcon items={items} />
164+
) : (
165+
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
166+
)
160167
const isMainAgent = agentName === 'mothership'
161168
// Collapsed status line: the latest tool call, always in its RUNNING
162169
// phrasing — it never flips to the completed rewrite (that lives in the
@@ -261,9 +268,7 @@ export function AgentGroupView({
261268
onClick={toggleExpanded}
262269
className='group/agent flex w-full min-w-0 cursor-pointer items-center gap-2 text-left'
263270
>
264-
<div className='flex size-[16px] shrink-0 items-center justify-center'>
265-
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
266-
</div>
271+
<div className='flex size-[16px] shrink-0 items-center justify-center'>{agentIcon}</div>
267272
{isWorking ? (
268273
<ShimmerText className='min-w-0 truncate text-sm'>{headerText}</ShimmerText>
269274
) : (
@@ -278,9 +283,7 @@ export function AgentGroupView({
278283
</button>
279284
) : (
280285
<div className='flex min-w-0 items-center gap-2'>
281-
<div className='flex size-[16px] shrink-0 items-center justify-center'>
282-
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
283-
</div>
286+
<div className='flex size-[16px] shrink-0 items-center justify-center'>{agentIcon}</div>
284287
{isWorking ? (
285288
<ShimmerText className='min-w-0 truncate text-sm'>{headerText}</ShimmerText>
286289
) : (
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
8+
import {
9+
BrowserAgentIcon,
10+
getBrowserAgentFaviconUrl,
11+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon'
12+
import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types'
13+
import { useBrowserSessionStore } from '@/stores/browser-session/store'
14+
15+
const { browserAvailable, chatIdentity } = vi.hoisted(() => ({
16+
browserAvailable: vi.fn(() => true),
17+
chatIdentity: { chatId: 'chat-1' },
18+
}))
19+
vi.mock('@/lib/browser-agent/transport', () => ({ isBrowserAgentAvailable: browserAvailable }))
20+
vi.mock('@/app/workspace/[workspaceId]/home/components/chat-surface-context', () => ({
21+
useChatSurface: () => chatIdentity,
22+
}))
23+
24+
function openPage(url: string, scopeId = 'chat-1', loading = false) {
25+
act(() =>
26+
useBrowserSessionStore.getState().setTabsState({
27+
scopeId,
28+
activeTabId: 'tab-1',
29+
automationTabId: 'tab-1',
30+
tabs: [{ tabId: 'tab-1', title: '', url, loading, active: true }],
31+
})
32+
)
33+
}
34+
35+
function tool(overrides: Partial<ToolCallData> = {}): AgentGroupItem {
36+
return {
37+
type: 'tool',
38+
data: {
39+
id: 'browser-tool',
40+
toolName: 'browser_navigate',
41+
displayTitle: 'Opening page',
42+
status: 'success',
43+
result: { success: true, output: { url: 'https://example.com/document' } },
44+
...overrides,
45+
},
46+
}
47+
}
48+
49+
describe('getBrowserAgentFaviconUrl', () => {
50+
it('keeps the observed origin and port without disclosing page details', () => {
51+
expect(
52+
getBrowserAgentFaviconUrl([
53+
tool({
54+
result: {
55+
success: true,
56+
output: {
57+
url: 'https://username:password@example.com:8443/document?token=private#part',
58+
},
59+
},
60+
}),
61+
])
62+
).toBe('https://example.com:8443/favicon.ico')
63+
})
64+
65+
it('uses the pending destination, then the observed redirect, and retains it during editing', () => {
66+
const navigating = tool({
67+
status: 'executing',
68+
params: { url: 'https://example.org/start' },
69+
result: undefined,
70+
})
71+
expect(getBrowserAgentFaviconUrl([tool(), navigating])).toBe('https://example.org/favicon.ico')
72+
const redirected = tool({ params: { url: 'https://example.org/start' } })
73+
expect(getBrowserAgentFaviconUrl([redirected])).toBe('https://example.com/favicon.ico')
74+
expect(
75+
getBrowserAgentFaviconUrl([
76+
redirected,
77+
tool({ toolName: 'browser_type', status: 'executing', result: undefined }),
78+
])
79+
).toBe('https://example.com/favicon.ico')
80+
})
81+
82+
it.each(['error', 'cancelled', 'rejected', 'awaiting_approval'] as const)(
83+
'does not adopt a destination from a %s tool',
84+
(status) => {
85+
expect(
86+
getBrowserAgentFaviconUrl([
87+
tool(),
88+
tool({ status, params: { url: 'https://example.org' }, result: undefined }),
89+
])
90+
).toBe('https://example.com/favicon.ico')
91+
}
92+
)
93+
94+
it.each([
95+
'',
96+
'about:blank',
97+
'http://example.com',
98+
'file:///document',
99+
'data:text/html,hello',
100+
'not a URL',
101+
])('clears the previous site when the page cannot supply an allowed favicon: %s', (url) => {
102+
expect(
103+
getBrowserAgentFaviconUrl([
104+
tool(),
105+
tool({ toolName: 'browser_switch_tab', result: { success: true, output: { url } } }),
106+
])
107+
).toBeNull()
108+
})
109+
110+
it.each(['browser_open_tab', 'browser_switch_tab', 'browser_close_tab', 'browser_go_back'])(
111+
'clears an obsolete site while %s has no known destination',
112+
(toolName) => {
113+
expect(
114+
getBrowserAgentFaviconUrl([
115+
tool(),
116+
tool({ toolName, status: 'executing', result: undefined }),
117+
])
118+
).toBeNull()
119+
}
120+
)
121+
122+
it('uses the agent tab from a tab list, including legacy results', () => {
123+
const tabs = [
124+
{ tabId: 'visible', url: 'https://example.org' },
125+
{ tabId: 'agent', url: 'https://example.com' },
126+
]
127+
for (const output of [
128+
{ tabs, activeTabId: 'visible', automationTabId: 'agent' },
129+
{ tabs, activeTabId: 'agent' },
130+
]) {
131+
expect(
132+
getBrowserAgentFaviconUrl([
133+
tool({ toolName: 'browser_list_tabs', result: { success: true, output } }),
134+
])
135+
).toBe('https://example.com/favicon.ico')
136+
}
137+
expect(
138+
getBrowserAgentFaviconUrl([
139+
tool(),
140+
tool({
141+
toolName: 'browser_list_tabs',
142+
result: {
143+
success: true,
144+
output: { tabs, activeTabId: 'visible', automationTabId: null },
145+
},
146+
}),
147+
])
148+
).toBeNull()
149+
})
150+
151+
it('follows a click into a new tab and screenshot page metadata', () => {
152+
for (const [toolName, output] of [
153+
[
154+
'browser_click',
155+
{ activeTab: { url: 'https://example.org' }, effect: { tabChanged: true } },
156+
],
157+
['browser_screenshot', { viewport: { url: 'https://example.org' } }],
158+
['browser_extract', { page: { url: 'https://example.org' } }],
159+
] as const) {
160+
expect(
161+
getBrowserAgentFaviconUrl([tool(), tool({ toolName, result: { success: true, output } })])
162+
).toBe('https://example.org/favicon.ico')
163+
}
164+
})
165+
166+
it('clears a stale site when an action navigated without reporting its destination', () => {
167+
expect(
168+
getBrowserAgentFaviconUrl([
169+
tool(),
170+
tool({
171+
toolName: 'browser_click',
172+
result: { success: true, output: { effect: { urlChanged: true } } },
173+
}),
174+
])
175+
).toBeNull()
176+
})
177+
178+
it('ignores URLs from other tools and nested agent runs', () => {
179+
expect(
180+
getBrowserAgentFaviconUrl([
181+
tool(),
182+
tool({
183+
toolName: 'browser_read_text',
184+
params: { elementId: 4 },
185+
result: { success: true, output: { url: 'https://example.org/embedded' } },
186+
}),
187+
tool({
188+
toolName: 'web_search',
189+
result: { success: true, output: { url: 'https://example.org' } },
190+
}),
191+
{
192+
type: 'agent_group',
193+
group: {
194+
id: 'other-run',
195+
agentName: 'browser',
196+
agentLabel: 'Browser',
197+
isOpen: true,
198+
isDelegating: true,
199+
items: [tool({ result: { success: true, output: { url: 'https://example.org' } } })],
200+
},
201+
},
202+
])
203+
).toBe('https://example.com/favicon.ico')
204+
})
205+
})
206+
207+
describe('BrowserAgentIcon', () => {
208+
let container: HTMLDivElement
209+
let root: Root
210+
211+
beforeEach(() => {
212+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
213+
browserAvailable.mockReturnValue(true)
214+
chatIdentity.chatId = 'chat-1'
215+
useBrowserSessionStore.setState({ sessions: {}, activeScopeId: null })
216+
container = document.createElement('div')
217+
root = createRoot(container)
218+
})
219+
220+
afterEach(() => act(() => root.unmount()))
221+
222+
const render = (url: string) => {
223+
act(() =>
224+
root.render(
225+
<BrowserAgentIcon items={[tool({ result: { success: true, output: { url } } })]} />
226+
)
227+
)
228+
}
229+
230+
it('does not contact sites from history, other chats, or pending navigation', () => {
231+
render('https://example.com/document')
232+
expect(container.querySelector('img')).toBeNull()
233+
openPage('https://example.com', 'another-chat')
234+
expect(container.querySelector('img')).toBeNull()
235+
openPage('https://example.com', 'chat-1', true)
236+
expect(container.querySelector('img')).toBeNull()
237+
openPage('https://example.org')
238+
expect(container.querySelector('img')).toBeNull()
239+
openPage('https://example.com')
240+
expect(container.querySelector('img')).not.toBeNull()
241+
})
242+
243+
it('requires the local desktop browser and retains an already loaded favicon after closing it', () => {
244+
browserAvailable.mockReturnValue(false)
245+
openPage('https://example.com')
246+
render('https://example.com')
247+
expect(container.querySelector('img')).toBeNull()
248+
browserAvailable.mockReturnValue(true)
249+
render('https://example.com')
250+
const img = container.querySelector('img')!
251+
act(() => img.dispatchEvent(new Event('load')))
252+
act(() => useBrowserSessionStore.getState().discardScope('chat-1'))
253+
expect(container.querySelector('img')).toBe(img)
254+
expect(container.querySelector('svg')).toBeNull()
255+
})
256+
257+
it('does not carry loaded state into another chat with the same destination', () => {
258+
openPage('https://example.com')
259+
render('https://example.com')
260+
act(() => container.querySelector('img')!.dispatchEvent(new Event('load')))
261+
chatIdentity.chatId = 'chat-2'
262+
render('https://example.com')
263+
expect(container.querySelector('img')).toBeNull()
264+
expect(container.querySelector('svg')).not.toBeNull()
265+
})
266+
267+
it('keeps the globe until load and resets image state when the page origin changes', () => {
268+
openPage('https://example.com/document')
269+
render('https://username:password@example.com/document?token=private#section')
270+
const firstImage = container.querySelector('img')!
271+
expect(firstImage.src).toBe('https://example.com/favicon.ico')
272+
expect(firstImage.getAttribute('referrerpolicy')).toBe('no-referrer')
273+
expect(container.querySelector('svg')).not.toBeNull()
274+
act(() => firstImage.dispatchEvent(new Event('load')))
275+
expect(container.querySelector('svg')).toBeNull()
276+
277+
render('https://example.com/another-document')
278+
expect(container.querySelector('img')).toBe(firstImage)
279+
expect(container.querySelector('svg')).toBeNull()
280+
281+
openPage('https://example.org/document')
282+
render('https://example.org/document')
283+
expect(container.querySelector('img')).not.toBe(firstImage)
284+
expect(container.querySelector('svg')).not.toBeNull()
285+
act(() => firstImage.dispatchEvent(new Event('error')))
286+
expect(container.querySelector('img')).not.toBeNull()
287+
act(() => container.querySelector('img')!.dispatchEvent(new Event('error')))
288+
expect(container.querySelector('img')).toBeNull()
289+
expect(container.querySelector('svg')).not.toBeNull()
290+
291+
openPage('https://example.com')
292+
render('https://example.com')
293+
expect(container.querySelector('img')).not.toBeNull()
294+
render('about:blank')
295+
expect(container.querySelector('img')).toBeNull()
296+
expect(container.querySelector('svg')).not.toBeNull()
297+
})
298+
})

0 commit comments

Comments
 (0)