Skip to content

Commit ce82c6e

Browse files
committed
improvement(search): restyle Search on the organization route
Both Search routes render the design-system surface: view switch, field, history, filters and result rows. The Assistant stays the real conversation, beside the surface. Behaviour comes from the shared hooks. E2E selectors: the filters are now Select controls, so their triggers are role combobox and their choices role option. No assertion changed.
1 parent b3182f6 commit ce82c6e

12 files changed

Lines changed: 1093 additions & 73 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ vi.mock('@/lib/mothership/chat/lifecycle', () => mothershipChatLifecycleMock)
1616
vi.mock('@/app/o/[organizationId]/integrations/integrations', () => ({
1717
OrganizationIntegrations: () => <div>Integrations</div>,
1818
}))
19-
vi.mock('@/app/o/[organizationId]/search/search', () => ({
20-
OrganizationSearch: () => <div>Standalone Search</div>,
19+
vi.mock('@/platform/app/(shell)/search/_components/search-surface/search-surface', () => ({
20+
OrganizationSearchSurface: () => <div>Standalone Search</div>,
2121
}))
2222
vi.mock('@/app/o/[organizationId]/components/organization-conversation', () => ({
2323
OrganizationConversation: ({ requestMode }: { requestMode?: string }) => (

‎apps/sim/app/o/[organizationId]/search/chat/[chatId]/page.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { getSession } from '@/lib/auth'
55
import { getAccessibleCopilotChatAuth } from '@/lib/mothership/chat/lifecycle'
66
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
77
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
8-
import { OrganizationSearch } from '@/app/o/[organizationId]/search/search'
98
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
9+
import { OrganizationSearchSurface } from '@/platform/app/(shell)/search/_components/search-surface/search-surface'
1010

1111
export const metadata: Metadata = { title: 'Assistant Search' }
1212

@@ -29,7 +29,7 @@ export default async function OrganizationSearchChatPage({
2929
if (chat.mode !== 'assistant') redirect(organizationRoutes(organizationId).chat(chatId))
3030
return (
3131
<Suspense fallback={<HomeFallback />}>
32-
<OrganizationSearch userId={session.user.id} chatId={chatId} />
32+
<OrganizationSearchSurface userId={session.user.id} chatId={chatId} />
3333
</Suspense>
3434
)
3535
}

‎apps/sim/app/o/[organizationId]/search/page.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { notFound, redirect } from 'next/navigation'
44
import { getSession } from '@/lib/auth'
55
import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
66
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
7-
import { OrganizationSearch } from '@/app/o/[organizationId]/search/search'
87
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
8+
import { OrganizationSearchSurface } from '@/platform/app/(shell)/search/_components/search-surface/search-surface'
99

1010
export const metadata: Metadata = { title: 'Search' }
1111

@@ -22,7 +22,7 @@ export default async function OrganizationSearchPage({
2222
if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
2323
return (
2424
<Suspense fallback={<HomeFallback />}>
25-
<OrganizationSearch userId={session.user.id} />
25+
<OrganizationSearchSurface userId={session.user.id} />
2626
</Suspense>
2727
)
2828
}

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

Lines changed: 85 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ import { OrganizationSearch } from '@/app/o/[organizationId]/search/search'
7373
import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results'
7474
import { searchHistoryKeys } from '@/hooks/queries/search-history'
7575
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
76+
import { SearchHistory } from '@/platform/app/(shell)/search/_components/search-surface/search-history'
77+
import { OrganizationSearchSurface } from '@/platform/app/(shell)/search/_components/search-surface/search-surface'
7678
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
7779

7880
nextNavigationMockFns.mockUsePathname.mockReturnValue('/o/organization/search')
@@ -141,23 +143,23 @@ async function render({
141143
scope = { kind: 'organization', organizationId: 'organization' },
142144
query = 'launch',
143145
params = '',
144-
organizationPage = false,
146+
organizationPage: OrganizationPage,
145147
filters,
146148
topK,
147149
}: {
148150
scope?: ResourceScope
149151
query?: string
150152
params?: string
151-
organizationPage?: boolean
153+
organizationPage?: typeof OrganizationSearch
152154
filters?: import('@/lib/api/contracts/knowledge').WorkspaceSearchFilters
153155
topK?: number
154156
} = {}) {
155157
await act(async () => {
156158
root.render(
157159
<QueryClientProvider client={client}>
158160
<NuqsTestingAdapter hasMemory searchParams={params} onUrlUpdate={mocks.urlUpdate}>
159-
{organizationPage ? (
160-
<OrganizationSearch userId='reader' />
161+
{OrganizationPage ? (
162+
<OrganizationPage userId='reader' />
161163
) : (
162164
<KnowledgeSearchResults
163165
scope={scope}
@@ -209,13 +211,16 @@ async function complete(
209211
})
210212
}
211213

212-
describe('live search submission feedback', () => {
214+
describe.each([
215+
['the app chrome', OrganizationSearch],
216+
['the organization route', OrganizationSearchSurface],
217+
])('live search submission feedback in %s', (_view, organizationPage) => {
213218
it.each(['launch', 'edited draft', ''])(
214219
'cancels with draft %j, ignores late results, and allows a fresh submission',
215220
async (draft) => {
216221
const shape = resolveDeploymentShape()
217222
seedDeploymentShape({ ...shape, features: shape.features })
218-
await render({ organizationPage: true, params: '?q=launch' })
223+
await render({ organizationPage, params: '?q=launch' })
219224
await act(async () => {
220225
await vi.advanceTimersByTimeAsync(1)
221226
})
@@ -267,16 +272,12 @@ describe('live search submission feedback', () => {
267272
it('acknowledges the submitted query before exposing refinement controls', async () => {
268273
const shape = resolveDeploymentShape()
269274
seedDeploymentShape({ ...shape, features: shape.features })
270-
await render({ organizationPage: true, params: '?q=launch' })
275+
await render({ organizationPage, params: '?q=launch' })
271276
await act(async () => {
272277
await vi.advanceTimersByTimeAsync(1)
273278
})
274279
expect(requests).toHaveLength(1)
275-
expect(
276-
[...container.querySelectorAll('button')].some(
277-
(element) => element.textContent === 'All sources'
278-
)
279-
).toBe(false)
280+
expect(container.querySelector('[role="group"][aria-label="Search filters"]')).toBeNull()
280281
expect(
281282
container.querySelector<HTMLButtonElement>('button[aria-label="Stop search"]')?.disabled
282283
).toBe(false)
@@ -312,55 +313,76 @@ describe('live search submission feedback', () => {
312313
})
313314
})
314315

315-
it('removes cached private history when access is denied during revalidation', async () => {
316-
client.setQueryData(searchHistoryKeys.list('organization', 'reader'), {
317-
sources: [
318-
{
319-
url: 'https://example.com/private',
320-
title: 'Private document',
321-
viewedAt: new Date().toISOString(),
322-
},
323-
],
324-
queries: [{ query: 'private query', searchedAt: new Date().toISOString() }],
325-
})
326-
await act(async () => {
327-
root.render(
328-
<QueryClientProvider client={client}>
329-
<SearchLandingHistory organizationId='organization' userId='reader' onSearch={() => {}}>
330-
<input aria-label='Search' />
331-
</SearchLandingHistory>
332-
</QueryClientProvider>
333-
)
334-
await vi.advanceTimersByTimeAsync(1)
335-
})
336-
expect(container.querySelector('a[href="https://example.com/private"]')).not.toBeNull()
337-
mockRequestJson.mockRejectedValue(new Error('Forbidden'))
338-
await act(async () => {
339-
await client.invalidateQueries({ queryKey: searchHistoryKeys.list('organization', 'reader') })
340-
await vi.advanceTimersByTimeAsync(1)
341-
})
342-
expect(container.querySelector('a[href="https://example.com/private"]')).toBeNull()
343-
expect(container.querySelector('[aria-label="Recent activity"]')).toBeNull()
344-
})
316+
it.each([
317+
[
318+
'the app chrome',
319+
<SearchLandingHistory
320+
key='chrome'
321+
organizationId='organization'
322+
userId='reader'
323+
onSearch={() => {}}
324+
>
325+
<input aria-label='Search' />
326+
</SearchLandingHistory>,
327+
],
328+
[
329+
'the organization route',
330+
<SearchHistory key='route' userId='reader' onSearch={() => {}}>
331+
<input aria-label='Search' />
332+
</SearchHistory>,
333+
],
334+
])(
335+
'removes cached private history when access is denied during revalidation in %s',
336+
async (_view, history) => {
337+
client.setQueryData(searchHistoryKeys.list('organization', 'reader'), {
338+
sources: [
339+
{
340+
url: 'https://example.com/private',
341+
title: 'Private document',
342+
viewedAt: new Date().toISOString(),
343+
},
344+
],
345+
queries: [{ query: 'private query', searchedAt: new Date().toISOString() }],
346+
})
347+
await act(async () => {
348+
root.render(<QueryClientProvider client={client}>{history}</QueryClientProvider>)
349+
await vi.advanceTimersByTimeAsync(1)
350+
})
351+
expect(container.querySelector('a[href="https://example.com/private"]')).not.toBeNull()
352+
mockRequestJson.mockRejectedValue(new Error('Forbidden'))
353+
await act(async () => {
354+
await client.invalidateQueries({ queryKey: searchHistoryKeys.list('organization', 'reader') })
355+
await vi.advanceTimersByTimeAsync(1)
356+
})
357+
expect(container.querySelector('a[href="https://example.com/private"]')).toBeNull()
358+
expect(container.querySelector('[aria-label="Recent activity"]')).toBeNull()
359+
}
360+
)
345361

346-
it('keeps an unsent draft when a different recent search is opened', async () => {
347-
const draftKey = 'reader:organization:organization:search'
348-
useMothershipDraftsStore
349-
.getState()
350-
.setDraft(draftKey, { text: 'unfinished question', searchQuery: '' })
351-
client.setQueryData(searchHistoryKeys.list('organization', 'reader'), {
352-
sources: [],
353-
queries: [{ query: 'launch', searchedAt: new Date().toISOString() }],
354-
})
355-
await render({ organizationPage: true })
356-
const recent = [...container.querySelectorAll('button')].find(
357-
(button) => button.textContent === 'launch'
358-
)
359-
expect(recent).not.toBeUndefined()
360-
await act(async () => {
361-
recent!.click()
362-
await vi.advanceTimersByTimeAsync(1)
363-
})
364-
expect(useMothershipDraftsStore.getState().drafts[draftKey]?.text).toBe('unfinished question')
365-
expect(requests.at(-1)?.body.query).toBe('launch')
366-
})
362+
it.each([
363+
['the app chrome', OrganizationSearch],
364+
['the organization route', OrganizationSearchSurface],
365+
])(
366+
'keeps an unsent draft when a different recent search is opened in %s',
367+
async (_view, organizationPage) => {
368+
const draftKey = 'reader:organization:organization:search'
369+
useMothershipDraftsStore
370+
.getState()
371+
.setDraft(draftKey, { text: 'unfinished question', searchQuery: '' })
372+
client.setQueryData(searchHistoryKeys.list('organization', 'reader'), {
373+
sources: [],
374+
queries: [{ query: 'launch', searchedAt: new Date().toISOString() }],
375+
})
376+
await render({ organizationPage })
377+
const recent = [...container.querySelectorAll('button')].find(
378+
(button) => button.textContent === 'launch'
379+
)
380+
expect(recent).not.toBeUndefined()
381+
await act(async () => {
382+
recent!.click()
383+
await vi.advanceTimersByTimeAsync(1)
384+
})
385+
expect(useMothershipDraftsStore.getState().drafts[draftKey]?.text).toBe('unfinished question')
386+
expect(requests.at(-1)?.body.query).toBe('launch')
387+
}
388+
)
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
'use client'
2+
3+
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
4+
import { useSession } from '@/lib/auth/client'
5+
import type { ResourceScope } from '@/lib/core/resource-scope'
6+
import { useLiveSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/use-live-search-results'
7+
import { useSearchFilterControls } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/use-search-filter-controls'
8+
import {
9+
handleResultsKeyDown,
10+
liveSearchResultsKey,
11+
toSource,
12+
} from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/utils'
13+
import styles from '@/platform/app/(shell)/_components/search/search.module.css'
14+
import { SourceRow } from '@/platform/app/(shell)/_components/search/source-row'
15+
import { Button, Chip } from '@/platform/design-system/components/button/button'
16+
import { EmptyState } from '@/platform/design-system/components/empty-state/empty-state'
17+
import { DateRangePicker } from '@/platform/design-system/components/picker/date-picker'
18+
import { Select } from '@/platform/design-system/components/picker/select'
19+
import { InlineNotice } from '@/platform/design-system/components/status/status'
20+
21+
function SearchFilterControls({ sourceTypes }: { sourceTypes: readonly string[] }) {
22+
const { source, updated, range } = useSearchFilterControls(sourceTypes)
23+
return (
24+
<div className={styles.filters} role='group' aria-label='Search filters'>
25+
<div className={styles.filter}>
26+
<Select
27+
variant='quiet'
28+
label={source.label}
29+
value={source.value}
30+
options={source.options}
31+
onValueChange={source.onChange}
32+
/>
33+
</div>
34+
<div className={styles.filter}>
35+
<Select
36+
variant='quiet'
37+
label={updated.label}
38+
value={updated.value}
39+
options={updated.options}
40+
onValueChange={updated.onChange}
41+
/>
42+
</div>
43+
{range && (
44+
<div className={styles.range}>
45+
<DateRangePicker
46+
label='Updated between'
47+
labelHidden
48+
placeholder='Updated between'
49+
clearable
50+
value={range.start && range.end ? { start: range.start, end: range.end } : null}
51+
onValueChange={(next) =>
52+
next ? range.onChange(next.start, next.end) : range.onClear()
53+
}
54+
/>
55+
</div>
56+
)}
57+
</div>
58+
)
59+
}
60+
61+
interface LiveResultsProps {
62+
scope: ResourceScope
63+
query: string
64+
onSummarize?: (prompt: string, filters: WorkspaceSearchFilters) => void
65+
}
66+
67+
function LiveResults({ scope, query, onSummarize }: LiveResultsProps) {
68+
const results = useLiveSearchResults({ scope, query, onSummarize })
69+
const { awaitingRange, isFetching, isPending, isError } = results
70+
return (
71+
<div className={styles.results} aria-busy={!awaitingRange && isFetching}>
72+
{!awaitingRange && isFetching && (
73+
<p className={styles.quiet} data-type='T060' role='status'>
74+
{isPending ? 'Searching' : 'Updating results'}
75+
</p>
76+
)}
77+
{awaitingRange && <p data-type='T060'>Choose the days to search.</p>}
78+
{isError && (
79+
<InlineNotice
80+
intent='danger'
81+
message='Search couldn’t run.'
82+
action={
83+
<Button variant='quiet' size='compact' onClick={() => void results.refetch()}>
84+
Try again
85+
</Button>
86+
}
87+
/>
88+
)}
89+
{results.showFilterControls && <SearchFilterControls sourceTypes={results.sourceTypes} />}
90+
{results.noResults && <EmptyState title='Search found no results.' />}
91+
{results.connectAppsHref && (
92+
<InlineNotice
93+
message='No connected apps are available for this search.'
94+
action={<Chip href={results.connectAppsHref}>Connect apps</Chip>}
95+
/>
96+
)}
97+
{results.someAppsUnavailable && (
98+
<InlineNotice
99+
intent='warning'
100+
message='Some apps couldn’t be searched. Showing available results.'
101+
/>
102+
)}
103+
{results.coverageIncomplete && (
104+
<InlineNotice
105+
intent='warning'
106+
message='Coverage is incomplete. Narrow the query or ask Assistant to refine it.'
107+
/>
108+
)}
109+
<div
110+
className={styles.rows}
111+
role='region'
112+
aria-label='Search results'
113+
aria-busy={isFetching}
114+
onKeyDown={handleResultsKeyDown}
115+
>
116+
{results.documents.map((result) => (
117+
<SourceRow
118+
key={result.documentId}
119+
source={toSource(result, query, scope)}
120+
query={query}
121+
onSummarize={results.summarizerFor(result.documentId)}
122+
/>
123+
))}
124+
</div>
125+
</div>
126+
)
127+
}
128+
129+
/** A query's results. A new query, reader or scope remounts them, restarting the date anchor. */
130+
export function SearchResults({ scope, query, onSummarize }: LiveResultsProps) {
131+
const { data: session } = useSession()
132+
return (
133+
<LiveResults
134+
key={liveSearchResultsKey(scope, session?.user?.id, query)}
135+
scope={scope}
136+
query={query}
137+
onSummarize={onSummarize}
138+
/>
139+
)
140+
}

0 commit comments

Comments
 (0)