Skip to content

Commit fef222f

Browse files
committed
Polish live Search results, citations, and effort controls
1 parent 9221551 commit fef222f

40 files changed

Lines changed: 736 additions & 44 deletions

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { useSpeechToText } from '@/hooks/use-speech-to-text'
88
import { useMothershipEffortStore } from '@/stores/mothership-effort/store'
99

1010
const mocks = vi.hoisted(() => ({
11+
live: false,
1112
speech: vi.fn<typeof useSpeechToText>(),
1213
toggleListening: vi.fn(),
1314
resetTranscript: vi.fn(),
@@ -33,6 +34,9 @@ const mocks = vi.hoisted(() => ({
3334
],
3435
}))
3536

37+
vi.mock('@/lib/core/config/deployment-shape', () => ({
38+
getDeploymentShape: () => ({ features: { liveEnterpriseSearch: mocks.live } }),
39+
}))
3640
vi.mock('@/hooks/queries/workspace', () => ({
3741
useWorkspacesQuery: () => ({ data: mocks.workspaces }),
3842
}))
@@ -83,6 +87,7 @@ let container: HTMLDivElement
8387
let queryClient: QueryClient
8488

8589
beforeEach(() => {
90+
mocks.live = false
8691
vi.clearAllMocks()
8792
mocks.workspaces = [
8893
{
@@ -832,3 +837,18 @@ it('offers only Fast, Auto, and Max search levels', async () => {
832837
)
833838
).toEqual(['Fast', 'Auto', 'Max'])
834839
})
840+
841+
it('offers only Auto and Max for live Search, with Auto sending the Fast preset', async () => {
842+
mocks.live = true
843+
const onChange = vi.fn()
844+
await render(true, '', 'assistant', { onAssistantSearchLevelChange: onChange })
845+
const picker = container.querySelector<HTMLButtonElement>('[aria-label="Search level"]')!
846+
expect(picker.textContent).toBe('Auto')
847+
await act(async () =>
848+
picker.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
849+
)
850+
const options = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')]
851+
expect(options.map((option) => option.textContent)).toEqual(['Auto', 'Max'])
852+
await act(async () => options[0].click())
853+
expect(onChange).toHaveBeenCalledWith('fast')
854+
})

‎apps/sim/app/o/[organizationId]/home/components/composer/search-level-selector.tsx‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import {
55
DropdownMenuItem,
66
DropdownMenuTrigger,
77
} from '@sim/emcn'
8-
import type { SearchLevel } from '@/app/o/[organizationId]/home/search-params'
8+
import { getDeploymentShape } from '@/lib/core/config/deployment-shape'
9+
import { resolveSearchLevel, type SearchLevel } from '@/app/o/[organizationId]/home/search-params'
910

1011
const SEARCH_LEVELS = [
1112
{ value: 'fast', label: 'Fast', description: 'Faster and cheaper' },
@@ -23,19 +24,28 @@ interface SearchLevelSelectorProps {
2324
}
2425

2526
export function SearchLevelSelector({ value, onChange }: SearchLevelSelectorProps) {
27+
const liveSearch = getDeploymentShape().features.liveEnterpriseSearch === true
28+
const selected = resolveSearchLevel(value, liveSearch)
29+
const levels = liveSearch
30+
? SEARCH_LEVELS.filter((level) => level.value !== 'adaptive').map((level) =>
31+
level.value === 'fast'
32+
? { ...level, label: 'Auto', description: 'Fast, everyday answers' }
33+
: level
34+
)
35+
: SEARCH_LEVELS
2636
return (
2737
<DropdownMenu>
2838
<DropdownMenuTrigger asChild>
2939
<Chip aria-label='Search level'>
30-
{SEARCH_LEVELS.find((level) => level.value === value)?.label}
40+
{levels.find((level) => level.value === selected)?.label}
3141
</Chip>
3242
</DropdownMenuTrigger>
3343
<DropdownMenuContent side='top' align='end'>
34-
{SEARCH_LEVELS.map((level) => (
44+
{levels.map((level) => (
3545
<DropdownMenuItem
3646
key={level.value}
3747
role='menuitemradio'
38-
aria-checked={value === level.value}
48+
aria-checked={selected === level.value}
3949
title={level.description}
4050
onSelect={() => onChange(level.value)}
4151
>

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { AuthorizedApp, AuthorizedAppsPage } from '@/lib/api/contracts/user
99
import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/store'
1010

1111
const mocks = vi.hoisted(() => ({
12+
live: false,
1213
context: vi.fn(),
1314
session: vi.fn(),
1415
push: vi.fn(),
@@ -28,6 +29,9 @@ const mocks = vi.hoisted(() => ({
2829
selectResource: vi.fn(),
2930
activeResource: null as string | null,
3031
}))
32+
vi.mock('@/lib/core/config/deployment-shape', () => ({
33+
getDeploymentShape: () => ({ features: { liveEnterpriseSearch: mocks.live } }),
34+
}))
3135
vi.mock('@/blocks/integration-matcher', () => ({ mentionifyIntegrations: (text: string) => text }))
3236
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mocks.push }) }))
3337
vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ fetchQuery: vi.fn() }) }))
@@ -82,6 +86,7 @@ import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-hom
8286
let root: Root
8387
let container: HTMLDivElement
8488
beforeEach(() => {
89+
mocks.live = false
8590
vi.clearAllMocks()
8691
mocks.activeResource = null
8792
mocks.session.mockReturnValue({ data: { user: { id: 'reader' } } })
@@ -877,3 +882,22 @@ it('does not reopen closed search results merely because a query remains in the
877882
await act(async () => composerProps().onChange('Different question'))
878883
expect(mocks.addResource).not.toHaveBeenCalled()
879884
})
885+
886+
it.each(['adaptive', 'fast', 'max'] as const)(
887+
'normalizes saved live Search level %s before sending',
888+
async (level) => {
889+
mocks.live = true
890+
useOrganizationChatModeStore
891+
.getState()
892+
.setAssistantSearchLevel('reader', 'organization-a', level)
893+
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
894+
expect(composerProps().assistantSearchLevel).toBe(level === 'max' ? 'max' : 'fast')
895+
await act(async () => composerProps().onSubmit('Find context'))
896+
expect(mocks.send).toHaveBeenCalledWith(
897+
'Find context',
898+
undefined,
899+
undefined,
900+
expect.objectContaining({ assistantSearchLevel: level === 'max' ? 'max' : 'fast' })
901+
)
902+
}
903+
)

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import { requestJson } from '@/lib/api/client/request'
99
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
1010
import { getWorkspaceHostContextContract } from '@/lib/api/contracts/workspaces'
1111
import { useSession } from '@/lib/auth/auth-client'
12+
import { getDeploymentShape } from '@/lib/core/config/deployment-shape'
1213
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
1314
import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview'
1415
import { createSearchResource } from '@/lib/mothership/resources/search'
1516
import { Composer } from '@/app/o/[organizationId]/home/components/composer'
1617
import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started'
1718
import {
1819
organizationHomeParsers,
20+
resolveSearchLevel,
1921
type SearchLevel,
2022
} from '@/app/o/[organizationId]/home/search-params'
2123
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
@@ -109,7 +111,11 @@ function OrganizationHomeContent({
109111
})
110112
const hasChat = Boolean(chatId || chat.messages.length)
111113
const canSelectMode = !hasChat && mothershipAvailable && canBuild && searchAccess.memberScoped
112-
const assistantSearchLevel = urlSearchLevel ?? rememberedSearchLevel
114+
const liveSearch = getDeploymentShape().features.liveEnterpriseSearch === true
115+
const assistantSearchLevel = resolveSearchLevel(
116+
urlSearchLevel ?? rememberedSearchLevel,
117+
liveSearch
118+
)
113119
const panel = useChatResourcePanel(chat, controller)
114120
const addResource = panel.addResourceFromUser
115121
/** Restore only an explicitly selected results tab on an empty Home; closing it clears the URL. */
@@ -206,13 +212,16 @@ function OrganizationHomeContent({
206212
: {}),
207213
...(requestMode === 'assistant'
208214
? {
209-
assistantSearchLevel: handoff.assistantSearchLevel ?? assistantSearchLevel,
215+
assistantSearchLevel: resolveSearchLevel(
216+
handoff.assistantSearchLevel ?? assistantSearchLevel,
217+
liveSearch
218+
),
210219
}
211220
: {}),
212221
...(handoff.assistantSearch ? { assistantSearch: handoff.assistantSearch } : {}),
213222
})
214223
}
215-
}, [chatId, organization.id, requestMode, sendMessage, assistantSearchLevel])
224+
}, [chatId, organization.id, requestMode, sendMessage, assistantSearchLevel, liveSearch])
216225

217226
const send = (
218227
message: string,
@@ -334,7 +343,9 @@ function OrganizationHomeContent({
334343
composer={composer}
335344
onWorkspaceResourceSelect={requestMode === 'agent' ? selectResource : undefined}
336345
initialScrollBlocked={
337-
requestMode === 'agent' && chat.resources.length > 0 && panel.isResourceCollapsed
346+
(requestMode === 'agent' || liveSearch) &&
347+
chat.resources.length > 0 &&
348+
panel.isResourceCollapsed
338349
}
339350
/>
340351
) : (

‎apps/sim/app/o/[organizationId]/home/search-params.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ import { organizationSearchParsers } from '@/app/o/[organizationId]/search/searc
44
import { searchFilterParsers } from '@/app/workspace/[workspaceId]/home/search-params'
55

66
export type SearchLevel = AssistantSearchLevel
7+
8+
/** Keep stored/URL choices compatible while live Search presents just Auto and Max. */
9+
export function resolveSearchLevel(
10+
value: SearchLevel | null | undefined,
11+
liveSearch: boolean
12+
): SearchLevel {
13+
return liveSearch ? (value === 'max' ? 'max' : 'fast') : (value ?? 'adaptive')
14+
}
715
export const SEARCH_LEVEL_VALUES = [
816
'fast',
917
'adaptive',

‎apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,8 @@ describe('live backend selection', () => {
304304
await render({ kind: 'organization', organizationId: 'org' })
305305
expect(mocks.index).not.toHaveBeenCalled()
306306
expect(mocks.overview).not.toHaveBeenCalled()
307-
expect(container.textContent).toContain('searched live as you')
307+
expect(container.textContent).not.toContain('searched live as you')
308+
expect(container.querySelector('[role="status"]')).toBeNull()
308309
expect(container.querySelector('a')?.getAttribute('href')).toBe('/o/org/integrations')
309310
expect(container.textContent).not.toContain('indexing')
310311
})

‎apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -427,24 +427,24 @@ function LiveSearchResults({
427427
]
428428
return (
429429
<div className='flex flex-col'>
430-
<div className='flex items-center gap-2 px-2 py-2'>
431-
{awaitingRange ? (
432-
<p className='text-caption'>Choose the days to search.</p>
433-
) : isPending || isFetching ? (
434-
<ActivityStatus label='Searching connected accounts…' isActive />
435-
) : (
436-
<p role='status' className='text-[var(--text-muted)] text-caption'>
437-
{isError
438-
? 'Search couldn’t run.'
439-
: `${documents.length} results · searched live as you`}
440-
</p>
441-
)}
442-
{isError && (
443-
<Chip variant='border' onClick={() => void refetch()}>
444-
Try again
445-
</Chip>
446-
)}
447-
</div>
430+
{(awaitingRange || isPending || isFetching || isError) && (
431+
<div className='flex items-center gap-2 px-2 py-2'>
432+
{awaitingRange ? (
433+
<p className='text-caption'>Choose the days to search.</p>
434+
) : isPending || isFetching ? (
435+
<ActivityStatus label='Searching connected accounts…' isActive />
436+
) : (
437+
<p role='status' className='text-[var(--text-muted)] text-caption'>
438+
Search couldn’t run.
439+
</p>
440+
)}
441+
{isError && (
442+
<Chip variant='border' onClick={() => void refetch()}>
443+
Try again
444+
</Chip>
445+
)}
446+
</div>
447+
)}
448448
{!suppliedFilters && (
449449
<div className='flex flex-wrap gap-2 px-2 py-2'>
450450
<Chip variant='border' onClick={() => void setParams({ source: null })}>

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-sources.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import {
22
parseSpecialTags,
33
type SourceTagData,
44
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
5+
import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations'
6+
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
57

68
/**
79
* Every distinct `<source>` cited across the given prose, in first-cited order,
@@ -19,3 +21,16 @@ export function collectMessageSources(texts: readonly string[]): SourceTagData[]
1921
}
2022
return [...byUrl.values()]
2123
}
24+
25+
/** Only main-answer citations populate the panel, never every fetched result or an agent's scratch work. */
26+
export function collectCitedMessageSources(
27+
blocks: readonly ContentBlock[],
28+
fallbackContent: string,
29+
requireEvidence = true
30+
): SourceTagData[] {
31+
const resolved = resolveMessageCitations(blocks, fallbackContent, requireEvidence)
32+
const texts = resolved.blocks
33+
.filter((block) => block.type === 'text' && !block.parentToolCallId && !block.subagent)
34+
.map((block) => block.content ?? '')
35+
return collectMessageSources(texts.length ? texts : [resolved.fallbackContent])
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/** @vitest-environment jsdom */
2+
import { act } from 'react'
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import { createRoot } from 'react-dom/client'
5+
import { expect, it, vi } from 'vitest'
6+
import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message'
7+
import { compactRetrievalCitations } from '@/lib/mothership/chat/retrieval-citations'
8+
import { SourcesResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/sources-resource-content'
9+
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
10+
11+
it('renders cited cached evidence, follows the latest answer, and never reruns provider searches', async () => {
12+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
13+
const fetch = vi.fn()
14+
vi.stubGlobal('fetch', fetch)
15+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
16+
const container = document.createElement('div')
17+
const root = createRoot(container)
18+
const citationId = `document:live:${'x'.repeat(700)}`
19+
const message: PersistedMessage = {
20+
id: 'persisted-answer',
21+
requestId: 'run-1',
22+
role: 'assistant',
23+
requestMode: 'assistant',
24+
timestamp: '2026-09-22T00:00:00Z',
25+
content: `Answer <source>{"id":"${citationId}"}</source>`,
26+
contentBlocks: [
27+
{
28+
type: 'tool',
29+
toolCall: {
30+
id: 'search',
31+
name: 'search_workspace',
32+
state: 'success',
33+
result: {
34+
success: true,
35+
output: compactRetrievalCitations('search_workspace', {
36+
success: true,
37+
data: {
38+
results: [
39+
{
40+
citationId,
41+
citationUrl: 'https://sim.slack.com/archives/G1/p123',
42+
documentName: 'Group DM · Sid, Waleed',
43+
connectorType: 'slack',
44+
author: 'Vikhyath Mondreti',
45+
content: '@Vikhyath Mondreti Please review the release notes.',
46+
},
47+
{
48+
citationId: 'unused',
49+
citationUrl: 'https://unused.test',
50+
documentName: 'Unused result',
51+
},
52+
],
53+
},
54+
}),
55+
},
56+
},
57+
},
58+
],
59+
}
60+
client.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail('chat'), {
61+
id: 'chat',
62+
title: 'Chat',
63+
messages: [
64+
message,
65+
{
66+
...message,
67+
id: 'second-answer',
68+
requestId: 'run-2',
69+
content: '<source>{"id":"unused"}</source>',
70+
},
71+
],
72+
activeStreamId: null,
73+
resources: [],
74+
})
75+
const render = (requestId: string) =>
76+
root.render(
77+
<QueryClientProvider client={client}>
78+
<SourcesResourceContent
79+
chatId='chat'
80+
resource={{
81+
type: 'sources',
82+
id: 'cited-sources',
83+
title: 'Sources',
84+
sources: { messageId: 'optimistic-answer', requestId },
85+
}}
86+
/>
87+
</QueryClientProvider>
88+
)
89+
try {
90+
await act(async () => render('run-1'))
91+
expect(container.textContent).toContain('Sources · 1')
92+
expect(container.textContent).toContain('Group DM · Sid, Waleed')
93+
expect(container.textContent).not.toContain('Unused result')
94+
expect(container.querySelector('a')?.href).toBe('https://sim.slack.com/archives/G1/p123')
95+
await act(async () => render('run-2'))
96+
expect(container.textContent).toContain('Unused result')
97+
expect(container.textContent).not.toContain('Group DM')
98+
expect(fetch).not.toHaveBeenCalled()
99+
} finally {
100+
await act(async () => root.unmount())
101+
client.clear()
102+
vi.unstubAllGlobals()
103+
}
104+
})

0 commit comments

Comments
 (0)