Skip to content

Commit 0703ed8

Browse files
committed
feat(chat): tailor Home controls and starters to the selected mode
1 parent 28fa67f commit 0703ed8

8 files changed

Lines changed: 210 additions & 96 deletions

File tree

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,8 @@ describe('organization voice composer', () => {
167167
async (isInitialView) => {
168168
await render(isInitialView)
169169
const mic = container.querySelector<HTMLButtonElement>('button[aria-label="Voice input"]')!
170-
expect(mic.previousElementSibling?.getAttribute('aria-label')).toBe(
171-
'Model and reasoning effort'
172-
)
170+
expect(container.querySelector('[aria-label="Model and reasoning effort"]')).toBeNull()
171+
expect(container.querySelector('[aria-label="Fast mode"]')).toBeNull()
173172
expect(mic.nextElementSibling?.getAttribute('aria-label')).toBe('Send')
174173
await act(async () => mic.click())
175174
expect(mocks.toggleListening).toHaveBeenCalledOnce()
@@ -406,12 +405,22 @@ it('keeps restored queued skills scoped when replacing a draft', async () => {
406405
])
407406
})
408407

409-
it('does not expose resource or skill controls in Search', async () => {
410-
await render(true)
411-
expect(container.querySelector('[aria-label="Skills"]')).toBeNull()
412-
expect(container.querySelector('[aria-label="Add resources"]')).toBeNull()
413-
expect(container.querySelector('[aria-label="Attach images"]')).not.toBeNull()
414-
})
408+
it.each([true, false])(
409+
'hides Build controls in Search (can choose mode: %s)',
410+
async (canChoose) => {
411+
await render(true, '', 'assistant', {
412+
isSending: false,
413+
showModeSelector: canChoose,
414+
onModeChange: vi.fn(),
415+
})
416+
expect(container.querySelector('[aria-label="Skills"]')).toBeNull()
417+
expect(container.querySelector('[aria-label="Add resources"]')).toBeNull()
418+
expect(container.querySelector('[aria-label="Model and reasoning effort"]')).toBeNull()
419+
expect(container.querySelector('[aria-label="Fast mode"]')).toBeNull()
420+
expect(Boolean(container.querySelector('[aria-label="Conversation mode"]'))).toBe(canChoose)
421+
expect(container.querySelector('[aria-label="Attach images"]')).not.toBeNull()
422+
}
423+
)
415424

416425
it('lists organization-owned legacy workspaces in the resource picker', async () => {
417426
await render(true, '', 'agent')

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,9 @@ export function Composer({
107107
editorRef.current.setValue(value)
108108
if (!value) editorRef.current.setContexts([])
109109
lastPublished.current = value
110+
if (value) textareaRef.current?.focus()
110111
}
111-
}, [value])
112+
}, [value, textareaRef])
112113
useEffect(() => {
113114
if (editorRef.current.getValue() !== editor.value) return
114115
if (editor.value !== lastPublished.current) {
@@ -146,8 +147,12 @@ export function Composer({
146147
const canSubmit =
147148
!files.attachedFiles.some((file) => file.uploading) &&
148149
(value.trim().length > 0 || files.attachedFiles.some((file) => file.key))
149-
const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)
150-
const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
150+
const animatedPlaceholder = useAnimatedPlaceholder(isInitialView && !imagesOnly)
151+
const placeholder = isInitialView
152+
? imagesOnly
153+
? 'Search your sources'
154+
: animatedPlaceholder
155+
: 'Send message to Sim'
151156

152157
const submit = () => {
153158
if (!canSubmit) return
@@ -213,6 +218,7 @@ export function Composer({
213218
/>
214219

215220
<InputToolbar
221+
showModelSelector={!imagesOnly}
216222
leadingControls={
217223
<>
218224
{!imagesOnly && contextPicker('resources', Plus, 'Add resources')}

‎apps/sim/app/o/[organizationId]/home/organization-home.test.tsx‎

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({
2323
upload: vi.fn(),
2424
addResource: vi.fn(),
2525
}))
26+
vi.mock('@/blocks/integration-matcher', () => ({ mentionifyIntegrations: (text: string) => text }))
2627
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mocks.push }) }))
2728
vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ fetchQuery: vi.fn() }) }))
2829
vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-resource-panel', () => ({
@@ -54,6 +55,13 @@ vi.mock('@/hooks/queries/mothership-chats', () => ({
5455
useMarkMothershipChatRead: () => ({ mutate: mocks.markRead }),
5556
}))
5657
vi.mock('@/app/o/[organizationId]/home/components/composer', () => ({ Composer: mocks.composer }))
58+
vi.mock('@/app/workspace/[workspaceId]/home/components/suggested-actions', () => ({
59+
SuggestedActions: ({ onSelectPrompt }: { onSelectPrompt: (prompt: string) => void }) => (
60+
<button onClick={() => onSelectPrompt('Create a CRM with sample data.')}>
61+
Suggested actions
62+
</button>
63+
),
64+
}))
5765
vi.mock('@/hooks/queries/kb/connectors', () => ({ useSearchSourceOverview: mocks.sources }))
5866
vi.mock('@/hooks/queries/api-keys', () => ({ useApiKeys: mocks.apiKeys }))
5967
vi.mock('@/hooks/queries/oauth-provider', () => ({ useAuthorizedApps: mocks.authorizedApps }))
@@ -80,7 +88,7 @@ beforeEach(() => {
8088
)
8189
mocks.upload.mockResolvedValue({ key: 'image-key', path: '/image-path' })
8290
mocks.context.mockReturnValue({
83-
organization: { id: 'organization-a' },
91+
organization: { id: 'organization-a', name: 'Acme' },
8492
searchAccess: { memberScoped: true },
8593
mothershipAvailable: true,
8694
canBuild: true,
@@ -159,7 +167,8 @@ describe('organization home', () => {
159167
await act(async () => renderHome(<OrganizationHome userName='Ada Lovelace' />))
160168
expect(container.textContent).toContain('What should we get done, Ada?')
161169
expect(container.textContent).toContain('Question composer')
162-
expect(container.textContent).toContain('Get started')
170+
expect(container.textContent).toContain('Suggested actions')
171+
expect(container.textContent).not.toContain('Get started')
163172
expect(mocks.renderer).not.toHaveBeenCalled()
164173
expect(mocks.chat).toHaveBeenCalledWith(
165174
{ organizationId: 'organization-a' },
@@ -240,13 +249,13 @@ describe('organization home', () => {
240249
'routes onboarding for admin=$isAdmin without a workspace creation requirement',
241250
async ({ isAdmin, integrationHref }) => {
242251
mocks.context.mockReturnValue({
243-
organization: { id: 'organization-a' },
252+
organization: { id: 'organization-a', name: 'Acme' },
244253
searchAccess: { memberScoped: true },
245254
mothershipAvailable: true,
246255
canBuild: true,
247256
viewer: { isAdmin, canUseSearchMcp: true },
248257
})
249-
await act(async () => renderHome(<OrganizationHome />))
258+
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
250259
expect(
251260
Array.from(container.querySelectorAll('a')).map((link) => ({
252261
label: link.textContent,
@@ -265,21 +274,21 @@ describe('organization home', () => {
265274
)
266275
it('does not complete MCP onboarding for an unrelated personal API key', async () => {
267276
mocks.apiKeys.mockReturnValue({ data: { personalKeys: [{ id: 'workflow-api-key' }] } })
268-
await act(async () => renderHome(<OrganizationHome />))
277+
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
269278
expect(hasCompletedMcpStep()).toBe(false)
270279
expect(mocks.apiKeys).not.toHaveBeenCalled()
271280
})
272281

273282
it('hides MCP onboarding and stops authorization paging when organization policy blocks access', async () => {
274283
mocks.context.mockReturnValue({
275-
organization: { id: 'organization-a' },
284+
organization: { id: 'organization-a', name: 'Acme' },
276285
searchAccess: { memberScoped: true },
277286
mothershipAvailable: true,
278287
canBuild: true,
279288
viewer: { isAdmin: false, canUseSearchMcp: false },
280289
})
281290
mockAuthorizedApps([{ apps: [], nextCursor: 'older-apps' }])
282-
await act(async () => renderHome(<OrganizationHome />))
291+
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
283292
expect(container.textContent).not.toContain('Connect Sim Search MCP')
284293
expect(container.textContent).toContain('Connect an integration')
285294
expect(mocks.authorizedApps).toHaveBeenCalledWith('', { enabled: false })
@@ -295,7 +304,7 @@ describe('organization home', () => {
295304
{ scopes: ['unrecognized:read'], completed: false },
296305
])('derives MCP completion from OAuth scopes $scopes', async ({ scopes, completed }) => {
297306
mockAuthorizedApps([{ apps: [authorizedApp(scopes)], nextCursor: null }])
298-
await act(async () => renderHome(<OrganizationHome />))
307+
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
299308
expect(hasCompletedMcpStep()).toBe(completed)
300309
})
301310

@@ -305,15 +314,15 @@ describe('organization home', () => {
305314
nextCursor: 'older-apps',
306315
}
307316
mockAuthorizedApps([firstPage])
308-
await act(async () => renderHome(<OrganizationHome />))
317+
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
309318
expect(hasCompletedMcpStep()).toBe(false)
310319
expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1)
311320

312321
mockAuthorizedApps([
313322
firstPage,
314323
{ apps: [authorizedApp(['search:read'])], nextCursor: 'even-older-apps' },
315324
])
316-
await act(async () => renderHome(<OrganizationHome />))
325+
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
317326
expect(hasCompletedMcpStep()).toBe(true)
318327
expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1)
319328
})
@@ -322,19 +331,19 @@ describe('organization home', () => {
322331
'does not start another authorization page request while %j',
323332
async (state) => {
324333
mockAuthorizedApps([{ apps: [], nextCursor: 'older-apps' }], state)
325-
await act(async () => renderHome(<OrganizationHome />))
334+
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
326335
expect(mocks.fetchNextPage).not.toHaveBeenCalled()
327336
expect(hasCompletedMcpStep()).toBe(false)
328337
}
329338
)
330339

331340
it('clears MCP completion when the Search authorization is revoked', async () => {
332341
mockAuthorizedApps([{ apps: [authorizedApp(['search:read'])], nextCursor: null }])
333-
await act(async () => renderHome(<OrganizationHome />))
342+
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
334343
expect(hasCompletedMcpStep()).toBe(true)
335344

336345
mockAuthorizedApps([{ apps: [], nextCursor: null }])
337-
await act(async () => renderHome(<OrganizationHome />))
346+
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
338347
expect(hasCompletedMcpStep()).toBe(false)
339348
})
340349

@@ -459,7 +468,7 @@ describe('organization home', () => {
459468

460469
it('uses agent mode on new Home and does not consume a Search handoff', async () => {
461470
mocks.context.mockReturnValue({
462-
organization: { id: 'organization-a' },
471+
organization: { id: 'organization-a', name: 'Acme' },
463472
mothershipAvailable: true,
464473
canBuild: true,
465474
searchAccess: { memberScoped: false },
@@ -642,3 +651,22 @@ it('keeps the latest submitted Search query when the next turn is switched to Bu
642651
undefined
643652
)
644653
})
654+
655+
it('fills the Build draft from suggested actions without sending', async () => {
656+
await act(async () => renderHome(<OrganizationHome userName='Ada Lovelace' />))
657+
expect(container.querySelector('h1')?.textContent).toBe('What should we get done, Ada?')
658+
const suggestion = [...container.querySelectorAll('button')].find(
659+
(button) => button.textContent === 'Suggested actions'
660+
)!
661+
await act(async () => suggestion.click())
662+
expect(composerProps().value).toBe('Create a CRM with sample data.')
663+
expect(mocks.send).not.toHaveBeenCalled()
664+
})
665+
666+
it('shows staging Search heading and setup steps instead of Build suggestions', async () => {
667+
await act(async () => renderHome(<OrganizationHome />))
668+
await act(async () => composerProps().onModeChange?.('assistant'))
669+
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
670+
expect(container.textContent).toContain('Get started')
671+
expect(container.textContent).not.toContain('Suggested actions')
672+
})

‎apps/sim/app/o/[organizationId]/home/organization-home.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organ
1616
import { ChatResourcePanel } from '@/app/workspace/[workspaceId]/home/components/chat-resource-panel'
1717
import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
1818
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat'
19+
import { SuggestedActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions'
1920
import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
2021
import {
2122
useChatResourcePanel,
@@ -28,6 +29,7 @@ import type {
2829
WorkspaceResourceRef,
2930
} from '@/app/workspace/[workspaceId]/home/types'
3031
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
32+
import { mentionifyIntegrations } from '@/blocks/integration-matcher'
3133
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
3234
import { getWorkspaceFilesQueryOptions } from '@/hooks/queries/workspace-files'
3335
import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/store'
@@ -267,13 +269,22 @@ function OrganizationHomeContent({
267269
{/* Asymmetric padding biases the group up so the full cluster (heading + input + steps) sits at the optical center */}
268270
<div className='flex min-h-full flex-col items-center justify-center px-6 pt-[2vh] pb-[22vh]'>
269271
<h1 className='mb-7 max-w-chat text-balance font-season text-[26px] text-[var(--text-primary)] leading-[1.15] tracking-[-0.01em] sm:text-[28px]'>
270-
What should we get done{firstName ? `, ${firstName}` : ''}?
272+
{requestMode === 'assistant'
273+
? `Search ${organization.name}`
274+
: `What should we get done${firstName ? `, ${firstName}` : ''}?`}
271275
</h1>
272276
<div className='relative w-full max-w-chat'>
273277
{composer}
274278
{/* Anchored out of flow so expanding/collapsing never shifts the centered input */}
275279
<div className='absolute inset-x-0 top-full'>
276-
{searchAccess.memberScoped && <GetStarted />}
280+
{requestMode === 'agent' ? (
281+
<SuggestedActions
282+
organizationId={organization.id}
283+
onSelectPrompt={(prompt) => setDraft(mentionifyIntegrations(prompt))}
284+
/>
285+
) : searchAccess.memberScoped ? (
286+
<GetStarted />
287+
) : null}
277288
</div>
278289
</div>
279290
</div>

‎apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { act } from 'react'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockCaptureEvent } = vi.hoisted(() => ({
8+
const { mockCaptureEvent, mockCredentials } = vi.hoisted(() => ({
99
mockCaptureEvent: vi.fn(),
10+
mockCredentials: vi.fn(() => ({ data: [] })),
1011
}))
1112

1213
vi.mock('next/navigation', () => ({
@@ -17,7 +18,7 @@ vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent }))
1718
vi.mock('@sim/utils/random', () => ({ randomFloat: () => 0 }))
1819

1920
vi.mock('@/hooks/queries/credentials', () => ({
20-
useWorkspaceCredentials: () => ({ data: [] }),
21+
useWorkspaceCredentials: mockCredentials,
2122
}))
2223
vi.mock('@/hooks/queries/oauth/oauth-connections', () => ({
2324
useOAuthConnections: () => ({ data: [] }),
@@ -42,12 +43,16 @@ let root: Root | null = null
4243
let container: HTMLDivElement | null = null
4344
const onSelectPrompt = vi.fn()
4445

45-
function mount() {
46+
function mount(organizationId?: string) {
4647
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
4748
container = document.createElement('div')
4849
document.body.appendChild(container)
4950
root = createRoot(container)
50-
act(() => root?.render(<SuggestedActions onSelectPrompt={onSelectPrompt} />))
51+
act(() =>
52+
root?.render(
53+
<SuggestedActions organizationId={organizationId} onSelectPrompt={onSelectPrompt} />
54+
)
55+
)
5156
}
5257

5358
function heading(): string {
@@ -63,6 +68,7 @@ function rows(): HTMLButtonElement[] {
6368
beforeEach(() => {
6469
onSelectPrompt.mockClear()
6570
mockCaptureEvent.mockClear()
71+
mockCredentials.mockClear()
6672
})
6773

6874
afterEach(() => {
@@ -88,3 +94,26 @@ describe('SuggestedActions', () => {
8894
expect(onSelectPrompt).toHaveBeenCalledWith('Create a CRM with sample data.')
8995
})
9096
})
97+
98+
it('uses org integration suggestions as prompts without opening workspace OAuth', () => {
99+
mount('organization-1')
100+
act(() =>
101+
rows()
102+
.find((row) => row.textContent === 'Integrate with Slack')
103+
?.click()
104+
)
105+
expect(onSelectPrompt).toHaveBeenCalledWith('Integrate with Slack.')
106+
expect(container?.querySelector('[data-testid="connect-modal"]')).toBeNull()
107+
expect(mockCredentials).toHaveBeenCalledWith({ workspaceId: undefined, enabled: false })
108+
})
109+
110+
it('keeps workspace integration suggestions connected to their OAuth flow', () => {
111+
mount()
112+
act(() =>
113+
rows()
114+
.find((row) => row.textContent === 'Integrate with Slack')
115+
?.click()
116+
)
117+
expect(container?.querySelector('[data-testid="connect-modal"]')).not.toBeNull()
118+
expect(onSelectPrompt).not.toHaveBeenCalled()
119+
})

0 commit comments

Comments
 (0)