Skip to content

Commit 29cff7b

Browse files
committed
fix(composer): include Browser in the resource picker
1 parent a5badf0 commit 29cff7b

2 files changed

Lines changed: 209 additions & 8 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, createRef } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const fixtures = vi.hoisted(() => ({
9+
browserAvailable: vi.fn(() => true),
10+
terminalAvailable: vi.fn(() => true),
11+
resources: { data: [{ id: 'resource-1', name: 'Example' }], isPending: false },
12+
folders: { data: [], isPending: false },
13+
tabs: [],
14+
logs: {
15+
data: {
16+
pages: [{ logs: [{ id: 'log-1', createdAt: '2026-01-01T12:00:00Z', status: 'success' }] }],
17+
},
18+
isPending: false,
19+
},
20+
}))
21+
22+
vi.mock('@/lib/browser-agent/transport', () => ({
23+
isBrowserAgentAvailable: fixtures.browserAvailable,
24+
}))
25+
vi.mock('@/lib/terminal/transport', () => ({
26+
isTerminalAvailable: fixtures.terminalAvailable,
27+
}))
28+
vi.mock('@/hooks/queries/workflows', () => ({ useWorkflows: () => fixtures.resources }))
29+
vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => fixtures.resources }))
30+
vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => fixtures.resources }))
31+
vi.mock('@/hooks/queries/kb/knowledge', () => ({
32+
useKnowledgeBasesQuery: () => fixtures.resources,
33+
}))
34+
vi.mock('@/hooks/queries/folders', () => ({ useFolders: () => fixtures.folders }))
35+
vi.mock('@/hooks/queries/workspace-file-folders', () => ({
36+
useWorkspaceFileFolders: () => fixtures.folders,
37+
}))
38+
vi.mock('@/hooks/queries/mothership-chats', () => ({
39+
useMothershipChats: () => fixtures.resources,
40+
}))
41+
vi.mock('@/hooks/queries/logs', () => ({ useLogsList: () => fixtures.logs }))
42+
vi.mock('@/blocks/integration-matcher', () => ({
43+
listIntegrationsByPopularity: () => [
44+
{ blockType: 'example', name: 'Example integration', icon: () => null },
45+
],
46+
}))
47+
vi.mock('@/stores/browser-session/store', () => ({ useBrowserSessionStore: () => fixtures.tabs }))
48+
vi.mock('@/stores/copilot-terminal/store', () => ({ useCopilotTerminalStore: () => fixtures.tabs }))
49+
50+
import {
51+
BROWSER_SESSION_RESOURCE_ID,
52+
TERMINAL_SESSION_RESOURCE_ID,
53+
} from '@/lib/copilot/resources/types'
54+
import {
55+
mapResourceToContext,
56+
type PlusMenuHandle,
57+
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
58+
import { PlusMenuDropdown } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown'
59+
60+
let root: Root
61+
let container: HTMLDivElement
62+
63+
function openMenu(mention = false) {
64+
const ref = createRef<PlusMenuHandle>()
65+
const onResourceSelect = vi.fn()
66+
act(() =>
67+
root.render(
68+
<PlusMenuDropdown
69+
ref={ref}
70+
workspaceId='workspace-1'
71+
onResourceSelect={onResourceSelect}
72+
onClose={vi.fn()}
73+
textareaRef={createRef<HTMLTextAreaElement>()}
74+
pendingCursorRef={{ current: null }}
75+
/>
76+
)
77+
)
78+
act(() => ref.current?.open({ left: 0, top: 0 }, { mention }))
79+
return { ref, onResourceSelect }
80+
}
81+
82+
function menuItems(): HTMLElement[] {
83+
return Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).filter(
84+
(item) => !item.closest('[hidden]')
85+
)
86+
}
87+
88+
function selectItem(name: string) {
89+
const item = menuItems().find((item) => item.textContent === name)
90+
if (!item) throw new Error(`Missing menu item: ${name}`)
91+
act(() => item.click())
92+
}
93+
94+
describe('PlusMenuDropdown desktop resources', () => {
95+
const originalScrollIntoView = Object.getOwnPropertyDescriptor(
96+
Element.prototype,
97+
'scrollIntoView'
98+
)
99+
100+
beforeEach(() => {
101+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
102+
vi.stubGlobal(
103+
'ResizeObserver',
104+
class {
105+
observe() {}
106+
unobserve() {}
107+
disconnect() {}
108+
}
109+
)
110+
vi.clearAllMocks()
111+
fixtures.browserAvailable.mockReturnValue(true)
112+
fixtures.terminalAvailable.mockReturnValue(true)
113+
Object.defineProperty(Element.prototype, 'scrollIntoView', {
114+
configurable: true,
115+
value: vi.fn(),
116+
})
117+
container = document.createElement('div')
118+
document.body.appendChild(container)
119+
root = createRoot(container)
120+
})
121+
122+
afterEach(() => {
123+
act(() => root.unmount())
124+
container.remove()
125+
if (originalScrollIntoView) {
126+
Object.defineProperty(Element.prototype, 'scrollIntoView', originalScrollIntoView)
127+
} else {
128+
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
129+
}
130+
vi.unstubAllGlobals()
131+
})
132+
133+
it('keeps shared categories in the same order in browse and mention modes', () => {
134+
const { ref } = openMenu()
135+
const browseOrder = menuItems().map((item) => item.textContent)
136+
expect(browseOrder).toEqual([
137+
'Chats',
138+
'Tables',
139+
'Files',
140+
'Knowledge Bases',
141+
'Workflows',
142+
'Logs',
143+
'Browser',
144+
'Terminal',
145+
])
146+
147+
act(() => ref.current?.open({ left: 0, top: 0 }, { mention: true }))
148+
const headings = menuItems().map((item) => item.previousElementSibling?.textContent)
149+
expect(headings).toEqual(['Integrations', ...browseOrder])
150+
})
151+
152+
it.each([false, true])('selects the same whole Browser in mention=%s mode', (mention) => {
153+
const { onResourceSelect } = openMenu(mention)
154+
selectItem('Browser')
155+
156+
expect(onResourceSelect).toHaveBeenCalledExactlyOnceWith({
157+
type: 'browser',
158+
id: BROWSER_SESSION_RESOURCE_ID,
159+
title: 'Browser',
160+
})
161+
expect(mapResourceToContext(onResourceSelect.mock.calls[0][0])).toEqual({
162+
kind: 'browser_tab',
163+
tabId: BROWSER_SESSION_RESOURCE_ID,
164+
label: 'Browser',
165+
})
166+
})
167+
168+
it('finds Browser through plus-menu search and selects it with Enter', () => {
169+
const { onResourceSelect } = openMenu()
170+
const search = document.querySelector<HTMLInputElement>(
171+
'input[placeholder="Search resources..."]'
172+
)
173+
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
174+
if (!search || !valueSetter) throw new Error('Search input is unavailable')
175+
act(() => {
176+
valueSetter.call(search, 'browser')
177+
search.dispatchEvent(new Event('input', { bubbles: true }))
178+
})
179+
expect(menuItems().map((item) => item.textContent)).toEqual(['Browser'])
180+
act(() => search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
181+
expect(onResourceSelect).toHaveBeenCalledExactlyOnceWith({
182+
type: 'browser',
183+
id: BROWSER_SESSION_RESOURCE_ID,
184+
title: 'Browser',
185+
})
186+
})
187+
188+
it.each([false, true])('keeps unavailable Browser hidden in mention=%s mode', (mention) => {
189+
fixtures.browserAvailable.mockReturnValue(false)
190+
const { onResourceSelect } = openMenu(mention)
191+
expect(menuItems().some((item) => item.textContent === 'Browser')).toBe(false)
192+
selectItem('Terminal')
193+
expect(onResourceSelect).toHaveBeenCalledExactlyOnceWith({
194+
type: 'terminal',
195+
id: TERMINAL_SESSION_RESOURCE_ID,
196+
title: 'Terminal',
197+
})
198+
})
199+
200+
it.each([false, true])('omits both desktop resources on web in mention=%s mode', (mention) => {
201+
fixtures.browserAvailable.mockReturnValue(false)
202+
fixtures.terminalAvailable.mockReturnValue(false)
203+
openMenu(mention)
204+
const names = menuItems().map((item) => item.textContent)
205+
expect(names).not.toContain('Browser')
206+
expect(names).not.toContain('Terminal')
207+
})
208+
})

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ const MENTION_MAX_HEIGHT_CLASS = 'max-h-[min(280px,var(--radix-popper-available-
5252
* (`ADD_RESOURCE_EXCLUDED_TYPES` in `resource-tabs`).
5353
*/
5454
const MENTION_ONLY_RESOURCE_TYPES = new Set<MothershipResourceType>(['integration'])
55-
const NON_ATTACHABLE_RESOURCE_TYPES = new Set<MothershipResourceType>(['browser'])
5655
const EMPTY_BROWSER_TABS = [] as const
5756
const EMPTY_TERMINAL_TABS = [] as const
5857

@@ -121,17 +120,11 @@ export const PlusMenuDropdown = React.memo(
121120
setOpen(false)
122121
}, [])
123122

124-
// The `+` browse menu hides non-attachable and mention-only resource types.
125-
// `@` mode exposes the full catalog and adds each live Browser/Terminal tab
126-
// after its always-present whole-resource row.
127123
const visibleResources = useMemo(() => {
128124
if (isMention) {
129125
return withDesktopTabMentions(availableResources, browserTabs, terminalTabs)
130126
}
131-
const attachable = availableResources.filter(
132-
({ type }) => !NON_ATTACHABLE_RESOURCE_TYPES.has(type)
133-
)
134-
return attachable.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type))
127+
return availableResources.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type))
135128
}, [availableResources, browserTabs, isMention, terminalTabs])
136129

137130
const treeSections = useResourceTreeSections({

0 commit comments

Comments
 (0)