Skip to content

Commit f52b352

Browse files
committed
improvement(home): fold Ask into Search behind an Answer toggle
1 parent 42edb67 commit f52b352

13 files changed

Lines changed: 167 additions & 46 deletions

File tree

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

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,4 @@ describe('SuggestedActions', () => {
128128
expect(document.querySelector('[data-testid="search-sources"]')).not.toBeNull()
129129
expect(rows()).toHaveLength(0)
130130
})
131-
132-
it('shows the sources in Ask mode, which answers from them', () => {
133-
mount()
134-
135-
act(() => useMothershipModeStore.getState().setMode('ask'))
136-
137-
expect(heading()).toBe('Sources')
138-
expect(document.querySelector('[data-testid="search-sources"]')).not.toBeNull()
139-
expect(rows()).toHaveLength(0)
140-
})
141131
})

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,6 @@ const INITIAL_ACTIONS: Action[] = [
234234
/** Section heading per composer mode — Search reads as a connect-your-sources list. */
235235
const HEADINGS: Record<MothershipMode, string> = {
236236
build: 'Suggested actions',
237-
ask: 'Sources',
238237
search: 'Sources',
239238
}
240239

@@ -373,7 +372,7 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
373372
`collapsible-up`/`-down` interpolate height alone, so a margin here
374373
would hold its full value through the close and then vanish on unmount,
375374
snapping the content below up. */}
376-
{mode !== 'build' && workspaceId ? (
375+
{mode === 'search' && workspaceId ? (
377376
<div className='pt-1.5'>
378377
<SearchSources workspaceId={workspaceId} />
379378
</div>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
8+
const { mockCaptureEvent } = vi.hoisted(() => ({ mockCaptureEvent: vi.fn() }))
9+
10+
vi.mock('next/navigation', () => ({
11+
useParams: () => ({ workspaceId: 'workspace-1' }),
12+
}))
13+
vi.mock('posthog-js/react', () => ({ usePostHog: () => null }))
14+
vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent }))
15+
16+
import { AnswerToggle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/answer-toggle/answer-toggle'
17+
import { useMothershipModeStore } from '@/stores/mothership-mode/store'
18+
19+
let root: Root | null = null
20+
let container: HTMLDivElement | null = null
21+
22+
function mount() {
23+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
24+
container = document.createElement('div')
25+
document.body.appendChild(container)
26+
root = createRoot(container)
27+
act(() => root?.render(<AnswerToggle />))
28+
}
29+
30+
function button(): HTMLButtonElement | null {
31+
return container?.querySelector('button') ?? null
32+
}
33+
34+
beforeEach(() => {
35+
mockCaptureEvent.mockClear()
36+
useMothershipModeStore.getState().reset()
37+
})
38+
39+
afterEach(() => {
40+
if (root) act(() => root?.unmount())
41+
container?.remove()
42+
root = null
43+
container = null
44+
})
45+
46+
describe('AnswerToggle', () => {
47+
it('renders nothing outside Search mode', () => {
48+
mount()
49+
expect(button()).toBeNull()
50+
})
51+
52+
it('shows an unpressed Answer chip in Search mode and flips the shared flag on click', () => {
53+
useMothershipModeStore.getState().setMode('search')
54+
mount()
55+
56+
const chip = button()
57+
expect(chip?.textContent).toBe('Answer')
58+
expect(chip?.getAttribute('aria-pressed')).toBe('false')
59+
60+
act(() => {
61+
chip?.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
62+
})
63+
64+
expect(useMothershipModeStore.getState().answer).toBe(true)
65+
expect(button()?.getAttribute('aria-pressed')).toBe('true')
66+
expect(mockCaptureEvent).toHaveBeenCalledWith(null, 'chat_answer_toggled', {
67+
workspace_id: 'workspace-1',
68+
enabled: true,
69+
})
70+
})
71+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use client'
2+
3+
import { memo } from 'react'
4+
import { Chip, Tooltip } from '@sim/emcn'
5+
import { useParams } from 'next/navigation'
6+
import { usePostHog } from 'posthog-js/react'
7+
import { captureEvent } from '@/lib/posthog/client'
8+
import { useMothershipModeStore } from '@/stores/mothership-mode/store'
9+
10+
/**
11+
* Search mode's Answer toggle: off, a query lists the matching documents; on,
12+
* Sim answers the question from those sources and may use the person's
13+
* connected tools. A label-only round `Chip` in its selected state while on,
14+
* sitting beside the mode switcher in the toolbar's row of round controls.
15+
*/
16+
export const AnswerToggle = memo(function AnswerToggle() {
17+
const { workspaceId } = useParams<{ workspaceId: string }>()
18+
const posthog = usePostHog()
19+
const mode = useMothershipModeStore((state) => state.mode)
20+
const answer = useMothershipModeStore((state) => state.answer)
21+
const setAnswer = useMothershipModeStore((state) => state.setAnswer)
22+
23+
if (mode !== 'search') return null
24+
25+
const handleToggle = () => {
26+
setAnswer(!answer)
27+
captureEvent(posthog, 'chat_answer_toggled', { workspace_id: workspaceId, enabled: !answer })
28+
}
29+
30+
return (
31+
<Tooltip.Root>
32+
<Tooltip.Trigger asChild>
33+
<Chip shape='round' active={answer} aria-pressed={answer} onClick={handleToggle}>
34+
Answer
35+
</Chip>
36+
</Tooltip.Trigger>
37+
<Tooltip.Content side='top'>
38+
{answer
39+
? 'Sim answers from your sources and can use your tools'
40+
: 'List matching documents'}
41+
</Tooltip.Content>
42+
</Tooltip.Root>
43+
)
44+
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { AnswerToggle } from './answer-toggle'

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { AnimatedPlaceholderEffect } from './animated-placeholder-effect'
2+
export { AnswerToggle } from './answer-toggle'
23
export { AttachedFilesList } from './attached-files-list'
34
export type { ParsedChipLink, PortableKind } from './chip-clipboard-codec'
45
export {

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,23 +78,22 @@ describe('ModeSwitcher', () => {
7878
expect(button.querySelector('svg')).toBeNull()
7979
})
8080

81-
it('lists every mode and checks the active one', () => {
81+
it('lists both modes and checks the active one', () => {
8282
mount()
8383
openMenu()
8484

8585
const rows = items()
86-
expect(rows.map((row) => row.textContent)).toEqual(['Build', 'Ask', 'Search'])
86+
expect(rows.map((row) => row.textContent)).toEqual(['Build', 'Search'])
8787
expect(rows[0].querySelector('svg')).not.toBeNull()
8888
expect(rows[1].querySelector('svg')).toBeNull()
89-
expect(rows[2].querySelector('svg')).toBeNull()
9089
})
9190

9291
it('switches the shared mode and reports the change', () => {
9392
mount()
9493
openMenu()
9594

9695
act(() => {
97-
items()[2].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
96+
items()[1].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
9897
})
9998

10099
expect(useMothershipModeStore.getState().mode).toBe('search')

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,11 @@ import {
2828

2929
const MODE_LABELS: Record<MothershipMode, string> = {
3030
build: 'Build',
31-
ask: 'Ask',
3231
search: 'Search',
3332
}
3433

3534
/**
36-
* The composer's Build / Ask / Search switcher: a label-only `Chip` in its `round`
35+
* The composer's Build / Search switcher: a label-only `Chip` in its `round`
3736
* shape — chip chrome throughout (`--text-body` label, `--surface-hover` on
3837
* hover, no text-color shift), fully round to sit in the toolbar's row of
3938
* round controls — opening a menu that checks the active mode, as

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
2121
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
2222
import {
2323
AnimatedPlaceholderEffect,
24+
AnswerToggle,
2425
AttachedFilesList,
2526
DropOverlay,
2627
MicButton,
@@ -719,6 +720,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
719720
</Tooltip.Root>
720721
</div>
721722
<div className='flex items-center gap-1.5'>
723+
{canSearch && <AnswerToggle />}
722724
{canSearch && <ModeSwitcher />}
723725
{isSttSupported && (
724726
<MicButton

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
195195
if (searchQuery) useMothershipModeStore.getState().setMode('search')
196196
}, [searchQuery])
197197
const composerMode = useMothershipModeStore((state) => state.mode)
198+
const answerMode = useMothershipModeStore((state) => state.answer)
198199
/** The bases an Ask turn is grounded in; read through a ref so a list refresh never rebuilds the submit handler. */
199200
const { data: knowledgeBases = EMPTY_KNOWLEDGE_BASES } = useKnowledgeBasesQuery(workspaceId)
200201
const knowledgeBasesRef = useRef(knowledgeBases)
@@ -487,11 +488,13 @@ export function Home({ chatId, userName, userId }: HomeProps) {
487488
})
488489

489490
/**
490-
* Search mode answers with documents, not a turn of the agent, and only
491+
* Search without Answer lists documents, not a turn of the agent, and only
491492
* a query can be answered: attachments alone have nothing to search for.
493+
* With Answer on, the query is a turn of the agent grounded in the sources.
492494
*/
493-
const mode = useMothershipModeStore.getState().mode
494-
if (mode === 'search') {
495+
const { mode, answer } = useMothershipModeStore.getState()
496+
const answering = mode === 'search' && answer
497+
if (mode === 'search' && !answer) {
495498
if (trimmed) setSearchQuery(trimmed)
496499
return
497500
}
@@ -501,18 +504,17 @@ export function Home({ chatId, userName, userId }: HomeProps) {
501504
}
502505

503506
prepareResourceViewForAgentTurn()
504-
const turnContexts =
505-
mode === 'ask'
506-
? withSearchedKnowledgeContexts(
507-
contexts,
508-
searchedKnowledgeBases(knowledgeBasesRef.current, workspaceId)
509-
)
510-
: contexts
507+
const turnContexts = answering
508+
? withSearchedKnowledgeContexts(
509+
contexts,
510+
searchedKnowledgeBases(knowledgeBasesRef.current, workspaceId)
511+
)
512+
: contexts
511513
sendMessage(
512514
trimmed || 'Analyze the attached file(s).',
513515
fileAttachments,
514516
turnContexts,
515-
mode === 'ask' ? { requestMode: 'ask' } : undefined
517+
answering ? { requestMode: 'ask' } : undefined
516518
)
517519
},
518520
[workspaceId, chatId, prepareResourceViewForAgentTurn, sendMessage, setSearchQuery]
@@ -521,20 +523,23 @@ export function Home({ chatId, userName, userId }: HomeProps) {
521523
/** An emptied search box returns to the sources; nothing else reads the cleared query. */
522524
const clearSearch = useCallback(() => setSearchQuery(''), [setSearchQuery])
523525

524-
/** Summarize or Answer on a result: hand the question to the agent in Ask mode. */
526+
/** Summarize or Answer on a result: turn Answer on and hand the question to the agent. */
525527
const handleSummarize = (prompt: string) => {
526-
useMothershipModeStore.getState().setMode('ask')
528+
const store = useMothershipModeStore.getState()
529+
store.setMode('search')
530+
store.setAnswer(true)
527531
setSearchQuery('')
528532
handleSubmit(prompt)
529533
}
530534
/**
531-
* A chat that already exists never opens in Search: its transcript is a
532-
* conversation, and search results never join it. Build and Ask both carry
533-
* over, so a follow-up question stays grounded in the sources.
535+
* A chat that already exists never opens in document-listing Search: its
536+
* transcript is a conversation, and search results never join it. Build and
537+
* Search with Answer both carry over, so a follow-up stays grounded in the
538+
* sources.
534539
*/
535540
useEffect(() => {
536541
const store = useMothershipModeStore.getState()
537-
if (chatId && store.mode === 'search') store.setMode('build')
542+
if (chatId && store.mode === 'search' && !store.answer) store.setMode('build')
538543
}, [chatId])
539544
const showSearchResults = composerMode === 'search' && searchQuery.trim().length > 0
540545
const searchResults = showSearchResults ? (
@@ -767,7 +772,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
767772
draftScopeKey={draftScopeKey}
768773
onSubmit={handleSubmit}
769774
canSearch
770-
clearOnSubmit={composerMode !== 'search'}
775+
clearOnSubmit={composerMode !== 'search' || answerMode}
771776
onCleared={clearSearch}
772777
isSending={isSending}
773778
onStopGeneration={handleStopGeneration}
@@ -796,7 +801,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
796801
isLoading={showChatSkeleton}
797802
onSubmit={handleSubmit}
798803
canSearch
799-
clearOnSubmit={composerMode !== 'search'}
804+
clearOnSubmit={composerMode !== 'search' || answerMode}
800805
onCleared={clearSearch}
801806
onStopGeneration={handleStopGeneration}
802807
messageQueue={messageQueue}

0 commit comments

Comments
 (0)