Skip to content

Commit c730028

Browse files
committed
improvement(chat): show browser agent site favicons
1 parent 1a82a17 commit c730028

3 files changed

Lines changed: 337 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: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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 } 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+
getBrowserAgentHostname,
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+
14+
function tool(overrides: Partial<ToolCallData> = {}): AgentGroupItem {
15+
return {
16+
type: 'tool',
17+
data: {
18+
id: 'browser-tool',
19+
toolName: 'browser_navigate',
20+
displayTitle: 'Opening page',
21+
status: 'success',
22+
result: { success: true, output: { url: 'https://example.com/document' } },
23+
...overrides,
24+
},
25+
}
26+
}
27+
28+
describe('getBrowserAgentHostname', () => {
29+
it('uses the pending destination, then the observed redirect, and retains it during editing', () => {
30+
const navigating = tool({
31+
status: 'executing',
32+
params: { url: 'https://example.org/start' },
33+
result: undefined,
34+
})
35+
expect(getBrowserAgentHostname([tool(), navigating])).toBe('example.org')
36+
const redirected = tool({ params: { url: 'https://example.org/start' } })
37+
expect(getBrowserAgentHostname([redirected])).toBe('example.com')
38+
expect(
39+
getBrowserAgentHostname([
40+
redirected,
41+
tool({ toolName: 'browser_type', status: 'executing', result: undefined }),
42+
])
43+
).toBe('example.com')
44+
})
45+
46+
it.each(['error', 'cancelled', 'rejected', 'awaiting_approval'] as const)(
47+
'does not adopt a destination from a %s tool',
48+
(status) => {
49+
expect(
50+
getBrowserAgentHostname([
51+
tool(),
52+
tool({ status, params: { url: 'https://example.org' }, result: undefined }),
53+
])
54+
).toBe('example.com')
55+
}
56+
)
57+
58+
it.each(['', 'about:blank', 'file:///document', 'data:text/html,hello', 'not a URL'])(
59+
'clears the previous site for a page without an HTTP hostname: %s',
60+
(url) => {
61+
expect(
62+
getBrowserAgentHostname([
63+
tool(),
64+
tool({ toolName: 'browser_switch_tab', result: { success: true, output: { url } } }),
65+
])
66+
).toBeNull()
67+
}
68+
)
69+
70+
it.each(['browser_open_tab', 'browser_switch_tab', 'browser_close_tab', 'browser_go_back'])(
71+
'clears an obsolete site while %s has no known destination',
72+
(toolName) => {
73+
expect(
74+
getBrowserAgentHostname([
75+
tool(),
76+
tool({ toolName, status: 'executing', result: undefined }),
77+
])
78+
).toBeNull()
79+
}
80+
)
81+
82+
it('uses the agent tab from a tab list, including legacy results', () => {
83+
const tabs = [
84+
{ tabId: 'visible', url: 'https://example.org' },
85+
{ tabId: 'agent', url: 'https://example.com' },
86+
]
87+
for (const output of [
88+
{ tabs, activeTabId: 'visible', automationTabId: 'agent' },
89+
{ tabs, activeTabId: 'agent' },
90+
]) {
91+
expect(
92+
getBrowserAgentHostname([
93+
tool({ toolName: 'browser_list_tabs', result: { success: true, output } }),
94+
])
95+
).toBe('example.com')
96+
}
97+
expect(
98+
getBrowserAgentHostname([
99+
tool(),
100+
tool({
101+
toolName: 'browser_list_tabs',
102+
result: {
103+
success: true,
104+
output: { tabs, activeTabId: 'visible', automationTabId: null },
105+
},
106+
}),
107+
])
108+
).toBeNull()
109+
})
110+
111+
it('follows a click into a new tab and screenshot page metadata', () => {
112+
for (const [toolName, output] of [
113+
[
114+
'browser_click',
115+
{ activeTab: { url: 'https://example.org' }, effect: { tabChanged: true } },
116+
],
117+
['browser_screenshot', { viewport: { url: 'https://example.org' } }],
118+
['browser_extract', { page: { url: 'https://example.org' } }],
119+
] as const) {
120+
expect(
121+
getBrowserAgentHostname([tool(), tool({ toolName, result: { success: true, output } })])
122+
).toBe('example.org')
123+
}
124+
})
125+
126+
it('clears a stale site when an action navigated without reporting its destination', () => {
127+
expect(
128+
getBrowserAgentHostname([
129+
tool(),
130+
tool({
131+
toolName: 'browser_click',
132+
result: { success: true, output: { effect: { urlChanged: true } } },
133+
}),
134+
])
135+
).toBeNull()
136+
})
137+
138+
it('ignores URLs from other tools and nested agent runs', () => {
139+
expect(
140+
getBrowserAgentHostname([
141+
tool(),
142+
tool({
143+
toolName: 'browser_read_text',
144+
params: { elementId: 4 },
145+
result: { success: true, output: { url: 'https://example.org/embedded' } },
146+
}),
147+
tool({
148+
toolName: 'web_search',
149+
result: { success: true, output: { url: 'https://example.org' } },
150+
}),
151+
{
152+
type: 'agent_group',
153+
group: {
154+
id: 'other-run',
155+
agentName: 'browser',
156+
agentLabel: 'Browser',
157+
isOpen: true,
158+
isDelegating: true,
159+
items: [tool({ result: { success: true, output: { url: 'https://example.org' } } })],
160+
},
161+
},
162+
])
163+
).toBe('example.com')
164+
})
165+
})
166+
167+
describe('BrowserAgentIcon', () => {
168+
let container: HTMLDivElement
169+
let root: Root
170+
171+
beforeEach(() => {
172+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
173+
container = document.createElement('div')
174+
root = createRoot(container)
175+
})
176+
177+
afterEach(() => act(() => root.unmount()))
178+
179+
const render = (url: string) => {
180+
act(() =>
181+
root.render(
182+
<BrowserAgentIcon items={[tool({ result: { success: true, output: { url } } })]} />
183+
)
184+
)
185+
}
186+
187+
it('keeps the globe until load and resets image state when the hostname changes', () => {
188+
render('https://example.com/document?token=private#section')
189+
const firstImage = container.querySelector('img')!
190+
expect(firstImage.src).toBe('https://www.google.com/s2/favicons?domain=example.com&sz=32')
191+
expect(container.querySelector('svg')).not.toBeNull()
192+
act(() => firstImage.dispatchEvent(new Event('load')))
193+
expect(container.querySelector('svg')).toBeNull()
194+
195+
render('https://example.com/another-document')
196+
expect(container.querySelector('img')).toBe(firstImage)
197+
expect(container.querySelector('svg')).toBeNull()
198+
199+
render('https://example.org/document')
200+
expect(container.querySelector('img')).not.toBe(firstImage)
201+
expect(container.querySelector('svg')).not.toBeNull()
202+
act(() => firstImage.dispatchEvent(new Event('error')))
203+
expect(container.querySelector('img')).not.toBeNull()
204+
act(() => container.querySelector('img')!.dispatchEvent(new Event('error')))
205+
expect(container.querySelector('img')).toBeNull()
206+
expect(container.querySelector('svg')).not.toBeNull()
207+
208+
render('https://example.com')
209+
expect(container.querySelector('img')).not.toBeNull()
210+
render('about:blank')
211+
expect(container.querySelector('img')).toBeNull()
212+
expect(container.querySelector('svg')).not.toBeNull()
213+
})
214+
})
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { isBrowserToolName } from '@sim/browser-protocol'
5+
import { cn } from '@sim/emcn'
6+
import { Globe } from '@sim/emcn/icons'
7+
import { isRecordLike } from '@sim/utils/object'
8+
import { browserTabHostname } from '@/lib/browser-agent/tab-label'
9+
import { faviconUrl } from '@/lib/core/utils/favicon'
10+
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
11+
import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
12+
13+
/** Uses this run's page observations so historical icons never follow another run's live tab. */
14+
export function getBrowserAgentHostname(items: AgentGroupItem[]): string | null {
15+
for (let index = items.length - 1; index >= 0; index--) {
16+
const item = items[index]
17+
if (item.type !== 'tool' || !isBrowserToolName(item.data.toolName)) continue
18+
const { toolName, status, params, result } = item.data
19+
if (status !== ToolCallStatus.executing && status !== ToolCallStatus.success) continue
20+
/** Element-scoped reads can report an iframe URL rather than the browser page. */
21+
if (toolName === 'browser_read_text' && params?.elementId !== undefined) continue
22+
23+
const navigatesToUrl =
24+
toolName === 'browser_navigate' ||
25+
toolName === 'browser_open_url' ||
26+
toolName === 'browser_open_tab'
27+
if (status === ToolCallStatus.executing && navigatesToUrl) {
28+
return typeof params?.url === 'string' ? browserTabHostname(params.url) : null
29+
}
30+
31+
const output = result?.success && isRecordLike(result.output) ? result.output : null
32+
if (output) {
33+
if (isRecordLike(output.activeTab) && typeof output.activeTab.url === 'string') {
34+
return browserTabHostname(output.activeTab.url)
35+
}
36+
if (typeof output.url === 'string') return browserTabHostname(output.url)
37+
if (
38+
toolName === 'browser_extract' &&
39+
isRecordLike(output.page) &&
40+
typeof output.page.url === 'string'
41+
) {
42+
return browserTabHostname(output.page.url)
43+
}
44+
if (
45+
toolName === 'browser_screenshot' &&
46+
isRecordLike(output.viewport) &&
47+
typeof output.viewport.url === 'string'
48+
) {
49+
return browserTabHostname(output.viewport.url)
50+
}
51+
if (toolName === 'browser_list_tabs' && Array.isArray(output.tabs)) {
52+
const tabId = 'automationTabId' in output ? output.automationTabId : output.activeTabId
53+
const tab = output.tabs.find((tab) => isRecordLike(tab) && tab.tabId === tabId)
54+
return isRecordLike(tab) && typeof tab.url === 'string' ? browserTabHostname(tab.url) : null
55+
}
56+
/** A navigation without a destination invalidates the previous page observation. */
57+
if (
58+
isRecordLike(output.effect) &&
59+
(output.effect.urlChanged === true ||
60+
output.effect.topUrlChanged === true ||
61+
output.effect.tabChanged === true)
62+
) {
63+
return null
64+
}
65+
}
66+
if (
67+
navigatesToUrl ||
68+
toolName === 'browser_switch_tab' ||
69+
toolName === 'browser_close_tab' ||
70+
toolName === 'browser_go_back' ||
71+
toolName === 'browser_go_forward'
72+
) {
73+
return null
74+
}
75+
}
76+
return null
77+
}
78+
79+
interface BrowserAgentIconProps {
80+
items: AgentGroupItem[]
81+
}
82+
83+
export function BrowserAgentIcon({ items }: BrowserAgentIconProps) {
84+
const hostname = getBrowserAgentHostname(items)
85+
return <BrowserAgentFavicon key={hostname} hostname={hostname} />
86+
}
87+
88+
interface BrowserAgentFaviconProps {
89+
hostname: string | null
90+
}
91+
92+
function BrowserAgentFavicon({ hostname }: BrowserAgentFaviconProps) {
93+
const [status, setStatus] = useState<'loading' | 'loaded' | 'failed'>('loading')
94+
95+
return (
96+
<span className='relative flex size-[16px] items-center justify-center' aria-hidden='true'>
97+
{hostname && status !== 'failed' && (
98+
<img
99+
src={faviconUrl(hostname, 32)}
100+
alt=''
101+
className={cn(
102+
'size-[16px] rounded-[3px]',
103+
status !== 'loaded' && 'pointer-events-none absolute opacity-0'
104+
)}
105+
onLoad={() => setStatus('loaded')}
106+
onError={() => setStatus('failed')}
107+
/>
108+
)}
109+
{(!hostname || status !== 'loaded') && (
110+
<Globe className='size-[16px] text-[var(--text-icon)]' />
111+
)}
112+
</span>
113+
)
114+
}

0 commit comments

Comments
 (0)