Skip to content

Commit 672462e

Browse files
committed
fix(search): align landing layout and history rows with Home
1 parent 0dd4c8d commit 672462e

7 files changed

Lines changed: 193 additions & 152 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { ReactNode } from 'react'
2+
import { cn, pageHeadingClassName } from '@sim/emcn'
3+
4+
interface OrganizationLandingProps {
5+
heading: string
6+
children: ReactNode
7+
}
8+
9+
/** Keeps the Home and Search composers centered independently of the shortcuts below them. */
10+
export function OrganizationLanding({ heading, children }: OrganizationLandingProps) {
11+
return (
12+
<div className='min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]'>
13+
<div className='flex min-h-full flex-col items-center justify-center px-6 pt-[2vh] pb-[22vh]'>
14+
<h1 className={cn(pageHeadingClassName, 'mb-7 max-w-chat')}>{heading}</h1>
15+
<div className='relative w-full max-w-chat'>{children}</div>
16+
</div>
17+
</div>
18+
)
19+
}

‎apps/sim/app/o/[organizationId]/components/search-landing-history.tsx‎

Lines changed: 74 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -29,86 +29,88 @@ export function SearchLandingHistory({
2929
const queries = data?.queries.slice(0, 5) ?? []
3030
const selected = selection ?? (sources.length > 0 ? 'sources' : 'queries')
3131
return (
32-
<div className={cn('w-full min-w-0', inter.className)}>
32+
<div className={cn('relative w-full min-w-0', inter.className)}>
3333
{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'>
34+
<div className='absolute inset-x-0 top-full'>
35+
{(sources.length > 0 || queries.length > 0) && (
36+
<section aria-label='Recent activity' className='mt-6 px-2'>
37+
<div className='mb-2 flex flex-wrap items-center justify-between gap-2'>
38+
<div role='group' aria-label='History type' className='flex items-center gap-1'>
39+
<Chip
40+
active={selected === 'sources'}
41+
aria-pressed={selected === 'sources'}
42+
onClick={() => setSelection('sources')}
43+
>
44+
Recently viewed
45+
</Chip>
46+
<Chip
47+
active={selected === 'queries'}
48+
aria-pressed={selected === 'queries'}
49+
onClick={() => setSelection('queries')}
50+
>
51+
Recent searches
52+
</Chip>
53+
</div>
3854
<Chip
39-
active={selected === 'sources'}
40-
aria-pressed={selected === 'sources'}
41-
onClick={() => setSelection('sources')}
55+
disabled={clear.isPending}
56+
onClick={() =>
57+
clear.mutate(undefined, { onError: (error) => toast.error(error.message) })
58+
}
4259
>
43-
Recently viewed
60+
Clear history
4461
</Chip>
45-
<Chip
46-
active={selected === 'queries'}
47-
aria-pressed={selected === 'queries'}
48-
onClick={() => setSelection('queries')}
62+
</div>
63+
<div className='grid grid-cols-1'>
64+
<div
65+
className={cn(
66+
'col-start-1 row-start-1 min-w-0',
67+
selected !== 'sources' && 'invisible'
68+
)}
69+
inert={selected !== 'sources'}
70+
aria-hidden={selected !== 'sources'}
4971
>
50-
Recent searches
51-
</Chip>
72+
{sources.length > 0 ? (
73+
sources.map((source) => <SourceCard key={source.url} source={source} dense />)
74+
) : (
75+
<p className='px-2 py-2 text-[var(--text-tertiary)] text-small'>
76+
Sources you open will appear here.
77+
</p>
78+
)}
79+
</div>
80+
<div
81+
className={cn(
82+
'col-start-1 row-start-1 min-w-0',
83+
selected !== 'queries' && 'invisible'
84+
)}
85+
inert={selected !== 'queries'}
86+
aria-hidden={selected !== 'queries'}
87+
>
88+
{queries.length > 0 ? (
89+
queries.map(({ query }) => (
90+
<div key={query} className='py-1'>
91+
<Chip fullWidth leftIcon={Clock} onClick={() => onSearch(query)}>
92+
{query}
93+
</Chip>
94+
</div>
95+
))
96+
) : (
97+
<p className='px-2 py-2 text-[var(--text-tertiary)] text-small'>
98+
Your recent searches will appear here.
99+
</p>
100+
)}
101+
</div>
52102
</div>
53-
<Chip
54-
disabled={clear.isPending}
55-
onClick={() =>
56-
clear.mutate(undefined, { onError: (error) => toast.error(error.message) })
57-
}
58-
>
59-
Clear history
103+
</section>
104+
)}
105+
{history.isError && (
106+
<div className='mt-6 flex items-center gap-2 px-4 text-[var(--text-tertiary)] text-small'>
107+
Recent activity couldn’t load.
108+
<Chip onClick={() => void history.refetch()} disabled={history.isFetching}>
109+
Try again
60110
</Chip>
61111
</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+
)}
113+
</div>
112114
</div>
113115
)
114116
}

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

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
4-
import { cn, pageHeadingClassName, toast } from '@sim/emcn'
4+
import { toast } from '@sim/emcn'
55
import { useQueryClient } from '@tanstack/react-query'
66
import Link from 'next/link'
77
import { useQueryStates } from 'nuqs'
@@ -16,6 +16,7 @@ import {
1616
getMothershipAttachmentUrl,
1717
} from '@/lib/mothership/chat/attachment-preview'
1818
import { createSearchResource } from '@/lib/mothership/resources/search'
19+
import { OrganizationLanding } from '@/app/o/[organizationId]/components/organization-landing'
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'
@@ -396,32 +397,27 @@ function OrganizationHomeContent({
396397
}
397398
/>
398399
) : (
399-
<div className='min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]'>
400-
{/* Asymmetric padding biases the group up so the full cluster (heading + input + steps) sits at the optical center */}
401-
<div className='flex min-h-full flex-col items-center justify-center px-6 pt-[2vh] pb-[22vh]'>
402-
<h1 className={cn(pageHeadingClassName, 'mb-7 max-w-chat')}>
403-
{requestMode === 'assistant'
404-
? `Search ${organization.name}`
405-
: requestMode === 'plan'
406-
? `What should we understand and plan${firstName ? `, ${firstName}` : ''}?`
407-
: `What should we get done${firstName ? `, ${firstName}` : ''}?`}
408-
</h1>
409-
<div className='relative w-full max-w-chat'>
410-
{composer}
411-
{/* Anchored out of flow so expanding/collapsing never shifts the centered input */}
412-
<div className='absolute inset-x-0 top-full'>
413-
{requestMode === 'agent' ? (
414-
<SuggestedActions
415-
organizationId={organization.id}
416-
onSelectPrompt={(prompt) => setDraft(mentionifyIntegrations(prompt))}
417-
/>
418-
) : searchAccess.memberScoped ? (
419-
<GetStarted />
420-
) : null}
421-
</div>
422-
</div>
400+
<OrganizationLanding
401+
heading={
402+
requestMode === 'assistant'
403+
? `Search ${organization.name}`
404+
: requestMode === 'plan'
405+
? `What should we understand and plan${firstName ? `, ${firstName}` : ''}?`
406+
: `What should we get done${firstName ? `, ${firstName}` : ''}?`
407+
}
408+
>
409+
{composer}
410+
<div className='absolute inset-x-0 top-full'>
411+
{requestMode === 'agent' ? (
412+
<SuggestedActions
413+
organizationId={organization.id}
414+
onSelectPrompt={(prompt) => setDraft(mentionifyIntegrations(prompt))}
415+
/>
416+
) : searchAccess.memberScoped ? (
417+
<GetStarted />
418+
) : null}
423419
</div>
424-
</div>
420+
</OrganizationLanding>
425421
)}
426422
</div>
427423
)

‎apps/sim/app/o/[organizationId]/search/search-results-view.tsx‎

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
import { type ReactNode, useRef } from 'react'
2-
import {
3-
cn,
4-
pageHeadingClassName,
5-
scrollFadeAttributes,
6-
scrollFadeClass,
7-
useScrollEdges,
8-
} from '@sim/emcn'
2+
import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
93
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
104
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
115
import type { SearchResource } from '@/lib/mothership/generated/resources'
6+
import { OrganizationLanding } from '@/app/o/[organizationId]/components/organization-landing'
127
import { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page'
138
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
149
import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results'
@@ -42,11 +37,11 @@ export function SearchResultsView({
4237
})
4338
return (
4439
<div className='flex h-full min-h-0 flex-col bg-[var(--bg)]'>
45-
<div className={PAGE_HEADER_BAR}>
46-
<div className={HEADER_ACTION_CLUSTER} />
47-
</div>
4840
{searching ? (
4941
<>
42+
<div className={PAGE_HEADER_BAR}>
43+
<div className={HEADER_ACTION_CLUSTER} />
44+
</div>
5045
<div className={cn(PAGE_COLUMN_CLASS, SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, 'shrink-0 pt-8')}>
5146
{composer}
5247
</div>
@@ -73,15 +68,9 @@ export function SearchResultsView({
7368
</div>
7469
</>
7570
) : (
76-
<div className='min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]'>
77-
{/* Asymmetric padding biases the group up so heading and field sit at the optical center, as on Home */}
78-
<div className='flex min-h-full flex-col items-center justify-center px-6 pt-[2vh] pb-[22vh]'>
79-
<h1 className={cn(pageHeadingClassName, 'mb-7 max-w-chat')}>
80-
Search {organization.name}
81-
</h1>
82-
<div className='w-full max-w-chat'>{composer}</div>
83-
</div>
84-
</div>
71+
<OrganizationLanding heading={`Search ${organization.name}`}>
72+
{composer}
73+
</OrganizationLanding>
8574
)}
8675
</div>
8776
)

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

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useEffect, useRef } from 'react'
44
import { ComposerActionButton, toast } from '@sim/emcn'
5-
import { ArrowUp, Loader } from '@sim/emcn/icons'
5+
import { ArrowUp, StopFilled } from '@sim/emcn/icons'
66
import { useIsFetching, useQueryClient } from '@tanstack/react-query'
77
import { useRouter } from 'next/navigation'
88
import { useQueryStates } from 'nuqs'
@@ -39,14 +39,13 @@ interface SearchFieldProps {
3939
function SearchField({ userId, initialValue, onSubmit }: SearchFieldProps) {
4040
const inputRef = useRef<HTMLTextAreaElement>(null)
4141
const { organization } = useOrganizationContext()
42-
const isSearching =
43-
useIsFetching({
44-
queryKey: knowledgeKeys.searchQuery(
45-
resourceScopeKey({ kind: 'organization', organizationId: organization.id }),
46-
initialValue.trim(),
47-
userId
48-
),
49-
}) > 0
42+
const queryClient = useQueryClient()
43+
const queryKey = knowledgeKeys.searchQuery(
44+
resourceScopeKey({ kind: 'organization', organizationId: organization.id }),
45+
initialValue.trim(),
46+
userId
47+
)
48+
const isSearching = useIsFetching({ queryKey }) > 0
5049
const latestDraftKey = `${userId}:organization:${organization.id}:search`
5150
const latestDraft = useMothershipDraftsStore((state) => state.drafts[latestDraftKey])
5251
const ownerQuery = initialValue || latestDraft?.searchQuery || ''
@@ -99,14 +98,14 @@ function SearchField({ userId, initialValue, onSubmit }: SearchFieldProps) {
9998
submitControl={
10099
<ComposerActionButton
101100
type='button'
102-
onClick={() => submit()}
103-
disabled={!canSubmit || pending}
104-
aria-label={pending ? 'Searching' : 'Search'}
101+
onClick={() => (pending ? void queryClient.cancelQueries({ queryKey }) : submit())}
102+
disabled={!canSubmit}
103+
aria-label={pending ? 'Stop search' : 'Search'}
105104
aria-busy={pending}
106105
active={canSubmit}
107106
>
108107
{pending ? (
109-
<Loader className='size-[16px] animate-spin text-white motion-reduce:animate-none dark:text-black' />
108+
<StopFilled className='block size-[14px] fill-white dark:fill-black' />
110109
) : (
111110
<ArrowUp className='block size-[16px] text-white dark:text-black' />
112111
)}

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,32 @@ describe('search refinement with the real query cache and URL state', () => {
272272
})
273273

274274
describe('live search submission feedback', () => {
275+
it('cancels a pending search without accepting its late response and allows a fresh submission', async () => {
276+
const shape = resolveDeploymentShape()
277+
seedDeploymentShape({ ...shape, features: { ...shape.features, liveEnterpriseSearch: true } })
278+
await render({ organizationPage: true, params: '?q=launch' })
279+
await act(async () => {
280+
await vi.advanceTimersByTimeAsync(1)
281+
})
282+
expect(requests).toHaveLength(1)
283+
await act(async () => {
284+
const stop = container.querySelector<HTMLButtonElement>('button[aria-label="Stop search"]')
285+
if (!stop) throw new Error('No stop control for the pending search')
286+
stop.click()
287+
await vi.advanceTimersByTimeAsync(1)
288+
})
289+
expect(requests[0].signal.aborted).toBe(true)
290+
await complete(0, { title: 'Cancelled result' })
291+
expect(container.querySelector('[data-source-link]')).toBeNull()
292+
await act(async () => {
293+
container.querySelector<HTMLButtonElement>('button[aria-label="Search"]')!.click()
294+
await vi.advanceTimersByTimeAsync(1)
295+
})
296+
expect(requests).toHaveLength(2)
297+
await complete(1, { title: 'Fresh result' })
298+
expect(container.querySelector('[data-source-link]')?.textContent).toBe('Fresh result')
299+
})
300+
275301
it('acknowledges the submitted query before exposing refinement controls', async () => {
276302
const shape = resolveDeploymentShape()
277303
seedDeploymentShape({ ...shape, features: { ...shape.features, liveEnterpriseSearch: true } })
@@ -286,8 +312,8 @@ describe('live search submission feedback', () => {
286312
)
287313
).toBe(false)
288314
expect(
289-
container.querySelector<HTMLButtonElement>('button[aria-label="Searching"]')?.disabled
290-
).toBe(true)
315+
container.querySelector<HTMLButtonElement>('button[aria-label="Stop search"]')?.disabled
316+
).toBe(false)
291317
await complete(0)
292318
expect(
293319
container.querySelector<HTMLButtonElement>('button[aria-label="Search"]')?.disabled

0 commit comments

Comments
 (0)