Skip to content

Commit 57e5622

Browse files
authored
feat(search): refine sources, history and result states (#8312)
* improvement(search): keep sources inline and show document icons * feat(search): add private history and responsive result states * fix(search): revalidate recent sources and polish state transitions * fix(search): keep private history independent of indexed search
1 parent 6f33c06 commit 57e5622

77 files changed

Lines changed: 30896 additions & 444 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
clearSearchHistoryContract,
3+
listSearchHistoryContract,
4+
recordSearchHistoryContract,
5+
} from '@/lib/api/contracts/knowledge/search-history'
6+
import {
7+
defineInternalJsonRoute,
8+
internalOrchestrationErrorPolicy,
9+
internalRateLimits,
10+
internalSessionAuth,
11+
} from '@/lib/api/server/routes'
12+
import {
13+
clearSearchHistory,
14+
listSearchHistory,
15+
recordSearchHistory,
16+
searchHistoryOperations,
17+
} from '@/lib/knowledge/application/search-history'
18+
19+
export const GET = defineInternalJsonRoute({
20+
contract: listSearchHistoryContract,
21+
auth: internalSessionAuth,
22+
operation: searchHistoryOperations.list,
23+
rateLimit: internalRateLimits.user({ bucketName: 'search-history-read' }),
24+
errorPolicy: internalOrchestrationErrorPolicy,
25+
mapInput: ({ params }) => ({ organizationId: params.id }),
26+
useCase: listSearchHistory,
27+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
28+
})
29+
export const POST = defineInternalJsonRoute({
30+
contract: recordSearchHistoryContract,
31+
auth: internalSessionAuth,
32+
operation: searchHistoryOperations.record,
33+
rateLimit: internalRateLimits.user({ bucketName: 'search-history-write' }),
34+
errorPolicy: internalOrchestrationErrorPolicy,
35+
parseOptions: { maxBodyBytes: 24 * 1024 },
36+
mapInput: ({ params, body }) => ({ organizationId: params.id, event: body }),
37+
useCase: recordSearchHistory,
38+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
39+
})
40+
export const DELETE = defineInternalJsonRoute({
41+
contract: clearSearchHistoryContract,
42+
auth: internalSessionAuth,
43+
operation: searchHistoryOperations.clear,
44+
rateLimit: internalRateLimits.user({ bucketName: 'search-history-write' }),
45+
errorPolicy: internalOrchestrationErrorPolicy,
46+
mapInput: ({ params }) => ({ organizationId: params.id }),
47+
useCase: clearSearchHistory,
48+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
49+
})

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,7 @@ export function OrganizationHeader({
145145
{logo}
146146
</DropdownMenuItem>
147147
</Tooltip.Trigger>
148-
<Tooltip.Content>
149-
{isUploadingLogo ? 'Uploading...' : 'Change logo'}
150-
</Tooltip.Content>
148+
<Tooltip.Content>{isUploadingLogo ? 'Uploading' : 'Change logo'}</Tooltip.Content>
151149
</Tooltip.Root>
152150
) : (
153151
logo
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
'use client'
2+
3+
import { type ReactNode, useState } from 'react'
4+
import { Chip, cn, toast } from '@sim/emcn'
5+
import { Clock } from '@sim/emcn/icons'
6+
import { inter } from '@/app/_styles/fonts/inter/inter'
7+
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
8+
import { useClearSearchHistory, useSearchHistory } from '@/hooks/queries/search-history'
9+
10+
interface SearchLandingHistoryProps {
11+
organizationId: string
12+
userId: string
13+
onSearch: (query: string) => void
14+
children: ReactNode
15+
}
16+
17+
/** Private shortcuts under the composer; ordinary buttons retain native Tab/Enter navigation. */
18+
export function SearchLandingHistory({
19+
organizationId,
20+
userId,
21+
onSearch,
22+
children,
23+
}: SearchLandingHistoryProps) {
24+
const history = useSearchHistory(organizationId)
25+
const clear = useClearSearchHistory(organizationId, userId)
26+
const [selection, setSelection] = useState<'sources' | 'queries' | null>(null)
27+
const data = history.isError ? undefined : history.data
28+
const sources = data?.sources.slice(0, 5) ?? []
29+
const queries = data?.queries.slice(0, 5) ?? []
30+
const selected = selection ?? (sources.length > 0 ? 'sources' : 'queries')
31+
return (
32+
<div className={cn('w-full min-w-0', inter.className)}>
33+
{children}
34+
{(sources.length > 0 || queries.length > 0) && (
35+
<section aria-label='Recent activity' className='mt-6 px-2'>
36+
<div className='mb-2 flex flex-wrap items-center justify-between gap-2'>
37+
<div role='group' aria-label='History type' className='flex items-center gap-1'>
38+
<Chip
39+
active={selected === 'sources'}
40+
aria-pressed={selected === 'sources'}
41+
onClick={() => setSelection('sources')}
42+
>
43+
Recently viewed
44+
</Chip>
45+
<Chip
46+
active={selected === 'queries'}
47+
aria-pressed={selected === 'queries'}
48+
onClick={() => setSelection('queries')}
49+
>
50+
Recent searches
51+
</Chip>
52+
</div>
53+
<Chip
54+
disabled={clear.isPending}
55+
onClick={() =>
56+
clear.mutate(undefined, { onError: (error) => toast.error(error.message) })
57+
}
58+
>
59+
Clear history
60+
</Chip>
61+
</div>
62+
<div className='grid grid-cols-1'>
63+
<div
64+
className={cn(
65+
'col-start-1 row-start-1 min-w-0',
66+
selected !== 'sources' && 'invisible'
67+
)}
68+
inert={selected !== 'sources'}
69+
aria-hidden={selected !== 'sources'}
70+
>
71+
{sources.length > 0 ? (
72+
sources.map((source) => <SourceCard key={source.url} source={source} dense />)
73+
) : (
74+
<p className='px-2 py-2 text-[var(--text-tertiary)] text-small'>
75+
Sources you open will appear here.
76+
</p>
77+
)}
78+
</div>
79+
<div
80+
className={cn(
81+
'col-start-1 row-start-1 min-w-0',
82+
selected !== 'queries' && 'invisible'
83+
)}
84+
inert={selected !== 'queries'}
85+
aria-hidden={selected !== 'queries'}
86+
>
87+
{queries.length > 0 ? (
88+
queries.map(({ query }) => (
89+
<div key={query} className='py-1'>
90+
<Chip fullWidth leftIcon={Clock} onClick={() => onSearch(query)}>
91+
{query}
92+
</Chip>
93+
</div>
94+
))
95+
) : (
96+
<p className='px-2 py-2 text-[var(--text-tertiary)] text-small'>
97+
Your recent searches will appear here.
98+
</p>
99+
)}
100+
</div>
101+
</div>
102+
</section>
103+
)}
104+
{history.isError && (
105+
<div className='mt-6 flex items-center gap-2 px-4 text-[var(--text-tertiary)] text-small'>
106+
Recent activity couldn’t load.
107+
<Chip onClick={() => void history.refetch()} disabled={history.isFetching}>
108+
Try again
109+
</Chip>
110+
</div>
111+
)}
112+
</div>
113+
)
114+
}

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ import {
1616
getMothershipAttachmentUrl,
1717
} from '@/lib/mothership/chat/attachment-preview'
1818
import { createSearchResource } from '@/lib/mothership/resources/search'
19+
import { SearchLandingHistory } from '@/app/o/[organizationId]/components/search-landing-history'
1920
import { Composer } from '@/app/o/[organizationId]/home/components/composer'
2021
import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started'
2122
import { organizationHomeParsers } from '@/app/o/[organizationId]/home/search-params'
2223
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
2324
import { organizationSearchUrlKeys } from '@/app/o/[organizationId]/search/search-params'
2425
import { ChatResourcePanel } from '@/app/workspace/[workspaceId]/home/components/chat-resource-panel'
26+
import { useSearchHistoryActions } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-history-context'
2527
import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
2628
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat'
2729
import { SuggestedActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions'
@@ -223,6 +225,7 @@ function OrganizationHomeContent({
223225
useEffect(() => {
224226
if (chat.error) toast.error(chat.error)
225227
}, [chat.error])
228+
const { recordQuery } = useSearchHistoryActions()
226229
const { sendMessage } = chat
227230
const { mutate: markRead } = useMarkMothershipChatRead({ organizationId: organization.id })
228231
const firstName = userName?.split(' ')[0] ?? ''
@@ -263,6 +266,7 @@ function OrganizationHomeContent({
263266
assistantSearch?: WorkspaceSearchFilters
264267
) => {
265268
if (requestMode !== 'assistant' && !canBuild) return
269+
if (requestMode === 'assistant') recordQuery(message)
266270
setSelectedMode(requestMode)
267271
if (requestMode !== 'assistant') panel.prepareResourceViewForAgentTurn()
268272
void sendMessage(message, fileAttachments, contexts, {
@@ -334,6 +338,14 @@ function OrganizationHomeContent({
334338
<div className='flex h-full min-h-0 min-w-[min(480px,100%)] flex-1 flex-col bg-[var(--bg)]'>
335339
{hasChat ? (
336340
<MothershipChat
341+
onViewSources={(messageId, requestId) =>
342+
addResource({
343+
type: 'sources',
344+
id: 'cited-sources',
345+
title: 'Sources',
346+
sources: { messageId, ...(requestId ? { requestId } : {}) },
347+
})
348+
}
337349
SearchConnectionComponent={SearchIntegrationConnection}
338350
messages={chat.messages}
339351
isSending={chat.isSending}
@@ -396,9 +408,18 @@ function OrganizationHomeContent({
396408
: `What should we get done${firstName ? `, ${firstName}` : ''}?`}
397409
</h1>
398410
<div className='relative w-full max-w-chat'>
399-
{composer}
400-
{/* Anchored out of flow so expanding/collapsing never shifts the centered input */}
401-
<div className='absolute inset-x-0 top-full'>
411+
{requestMode === 'assistant' && searchAccess.memberScoped && userId ? (
412+
<SearchLandingHistory
413+
organizationId={organization.id}
414+
userId={userId}
415+
onSearch={(query) => submit(query)}
416+
>
417+
{composer}
418+
</SearchLandingHistory>
419+
) : (
420+
composer
421+
)}
422+
<div className={requestMode === 'agent' ? 'absolute inset-x-0 top-full' : 'mt-4'}>
402423
{requestMode === 'agent' ? (
403424
<SuggestedActions
404425
organizationId={organization.id}

‎apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function DisconnectAccountMenu({
5959
text={`Disconnect ${selected ? accountLabel(selected) : integrationName} from all ${integrationName} connections in this organization. Workflows using this account will also lose access. You can reconnect later.`}
6060
confirm={{
6161
label: 'Disconnect',
62-
pendingLabel: 'Disconnecting…',
62+
pendingLabel: 'Disconnecting',
6363
pending: disconnect.isPending,
6464
disabled: disconnect.isPending,
6565
onClick: () => {

‎apps/sim/app/o/[organizationId]/integrations/github-member-integration.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export function GitHubMemberIntegration({
4646
const description = account
4747
? `${accounts.map((entry) => entry.displayName).join(', ')} · ${account.status === 'needs_reauth' ? 'Reconnect required' : 'Connected'}`
4848
: loading
49-
? 'Loading connection…'
49+
? 'Loading connection'
5050
: failed
5151
? 'Could not load connection'
5252
: option
@@ -68,7 +68,7 @@ export function GitHubMemberIntegration({
6868
/>
6969
{failed ? (
7070
<Chip disabled={inventory.isFetching} onClick={() => void inventory.refetch()}>
71-
{inventory.isFetching ? 'Retrying…' : 'Retry'}
71+
{inventory.isFetching ? 'Retrying' : 'Retry'}
7272
</Chip>
7373
) : account?.status === 'needs_reauth' && option?.id === account.optionId ? (
7474
<Chip

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,7 @@ describe('grouped member integrations', () => {
766766
expect(mocks.nextPage).toHaveBeenCalledOnce()
767767
queryOverrides = { hasNextPage: true, isFetchingNextPage: true, isFetching: true }
768768
await render()
769-
expect(buttons('Checking…')[0]).toBeDisabled()
769+
expect(buttons('Checking')[0]).toBeDisabled()
770770
rows = [
771771
...rows,
772772
{ ...memberSource, connectorId: 'older-source', viewerMembership: membership },

‎apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt
6565
/>
6666
)
6767
if (!inventory.data || !policies.data || !secrets.data)
68-
return <SettingsEmptyState variant='inline'>Loading your connections…</SettingsEmptyState>
68+
return <SettingsEmptyState variant='inline'>Loading your connections</SettingsEmptyState>
6969
const data = inventory.data
7070
const approvals = new Map(policies.data.map((policy) => [policy.connectorType, policy]))
7171
const group = data.credentialGroup
@@ -210,7 +210,7 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt
210210
connect.variables.optionId === option?.id) ||
211211
('mcpServerId' in connect.variables &&
212212
connect.variables.mcpServerId === server?.id))
213-
? 'Connecting…'
213+
? 'Connecting'
214214
: accounts.length
215215
? 'Add account'
216216
: 'Connect'}

‎apps/sim/app/o/[organizationId]/integrations/member-integration-row.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export function MemberIntegrationRow({
116116
function description() {
117117
if (!configured) return waiting ? 'Finish connecting in the other tab' : 'Not connected'
118118
if (hasLoadError) return 'Could not load connection'
119-
if (sources.isPending) return 'Loading connection…'
119+
if (sources.isPending) return 'Loading connection'
120120
if (target) {
121121
if (waiting) return 'Finish connecting in the other tab'
122122
if (target.viewerMembership === 'needs_reauth') return 'Reconnect your account'
@@ -183,7 +183,7 @@ export function MemberIntegrationRow({
183183
)}
184184
{hasLoadError && (
185185
<Chip disabled={sources.isFetching} onClick={() => void sources.refetch()}>
186-
{sources.isFetching ? 'Retrying…' : 'Retry'}
186+
{sources.isFetching ? 'Retrying' : 'Retry'}
187187
</Chip>
188188
)}
189189
{canCheckConnections && (
@@ -193,7 +193,7 @@ export function MemberIntegrationRow({
193193
onClick={() => void sources.fetchNextPage({ cancelRefetch: false })}
194194
>
195195
{sources.isFetchingNextPage
196-
? 'Checking…'
196+
? 'Checking'
197197
: sources.isFetchNextPageError
198198
? 'Retry'
199199
: 'Check connections'}

‎apps/sim/app/o/[organizationId]/integrations/member-integrations-list.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export function MemberIntegrationsList({
127127
</>
128128
) : overview.isPending || integrations.isPending ? (
129129
<>
130-
<SettingsEmptyState variant='inline'>Loading integrations…</SettingsEmptyState>
130+
<SettingsEmptyState variant='inline'>Loading integrations</SettingsEmptyState>
131131
{githubRow}
132132
</>
133133
) : (
@@ -178,7 +178,7 @@ export function MemberIntegrationsList({
178178
<SettingsEmptyState variant='inline'>
179179
{!availability.isIntegrationAvailabilityReady ||
180180
(approved.has('slack') && 'slack'.includes(query) && slackInventory.isPending)
181-
? 'Loading integrations…'
181+
? 'Loading integrations'
182182
: search
183183
? 'No matching integrations.'
184184
: 'No integrations are available to connect.'}

0 commit comments

Comments
 (0)