From 672462efab56fcd754785d1a54e794fbbabf2ff3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 21:51:03 -0700 Subject: [PATCH 1/2] fix(search): align landing layout and history rows with Home --- .../components/organization-landing.tsx | 19 +++ .../components/search-landing-history.tsx | 146 +++++++++--------- .../home/organization-home.tsx | 48 +++--- .../search/search-results-view.tsx | 27 +--- .../app/o/[organizationId]/search/search.tsx | 25 ++- .../search-transitions.test.tsx | 30 +++- .../components/source-card/source-card.tsx | 50 +++--- 7 files changed, 193 insertions(+), 152 deletions(-) create mode 100644 apps/sim/app/o/[organizationId]/components/organization-landing.tsx diff --git a/apps/sim/app/o/[organizationId]/components/organization-landing.tsx b/apps/sim/app/o/[organizationId]/components/organization-landing.tsx new file mode 100644 index 00000000000..4f58d030cef --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-landing.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react' +import { cn, pageHeadingClassName } from '@sim/emcn' + +interface OrganizationLandingProps { + heading: string + children: ReactNode +} + +/** Keeps the Home and Search composers centered independently of the shortcuts below them. */ +export function OrganizationLanding({ heading, children }: OrganizationLandingProps) { + return ( +
+
+

{heading}

+
{children}
+
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/search-landing-history.tsx b/apps/sim/app/o/[organizationId]/components/search-landing-history.tsx index 08a1ba2116d..ef50e9c3635 100644 --- a/apps/sim/app/o/[organizationId]/components/search-landing-history.tsx +++ b/apps/sim/app/o/[organizationId]/components/search-landing-history.tsx @@ -29,86 +29,88 @@ export function SearchLandingHistory({ const queries = data?.queries.slice(0, 5) ?? [] const selected = selection ?? (sources.length > 0 ? 'sources' : 'queries') return ( -
+
{children} - {(sources.length > 0 || queries.length > 0) && ( -
-
-
+
+ {(sources.length > 0 || queries.length > 0) && ( +
+
+
+ setSelection('sources')} + > + Recently viewed + + setSelection('queries')} + > + Recent searches + +
setSelection('sources')} + disabled={clear.isPending} + onClick={() => + clear.mutate(undefined, { onError: (error) => toast.error(error.message) }) + } > - Recently viewed + Clear history - setSelection('queries')} +
+
+
- Recent searches - + {sources.length > 0 ? ( + sources.map((source) => ) + ) : ( +

+ Sources you open will appear here. +

+ )} +
+
+ {queries.length > 0 ? ( + queries.map(({ query }) => ( +
+ onSearch(query)}> + {query} + +
+ )) + ) : ( +

+ Your recent searches will appear here. +

+ )} +
- - clear.mutate(undefined, { onError: (error) => toast.error(error.message) }) - } - > - Clear history +
+ )} + {history.isError && ( +
+ Recent activity couldn’t load. + void history.refetch()} disabled={history.isFetching}> + Try again
-
-
- {sources.length > 0 ? ( - sources.map((source) => ) - ) : ( -

- Sources you open will appear here. -

- )} -
-
- {queries.length > 0 ? ( - queries.map(({ query }) => ( -
- onSearch(query)}> - {query} - -
- )) - ) : ( -

- Your recent searches will appear here. -

- )} -
-
-
- )} - {history.isError && ( -
- Recent activity couldn’t load. - void history.refetch()} disabled={history.isFetching}> - Try again - -
- )} + )} +
) } diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index 0d44c285fa6..7baabe357df 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' -import { cn, pageHeadingClassName, toast } from '@sim/emcn' +import { toast } from '@sim/emcn' import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' import { useQueryStates } from 'nuqs' @@ -16,6 +16,7 @@ import { getMothershipAttachmentUrl, } from '@/lib/mothership/chat/attachment-preview' import { createSearchResource } from '@/lib/mothership/resources/search' +import { OrganizationLanding } from '@/app/o/[organizationId]/components/organization-landing' import { Composer } from '@/app/o/[organizationId]/home/components/composer' import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started' import { organizationHomeParsers } from '@/app/o/[organizationId]/home/search-params' @@ -396,32 +397,27 @@ function OrganizationHomeContent({ } /> ) : ( -
- {/* Asymmetric padding biases the group up so the full cluster (heading + input + steps) sits at the optical center */} -
-

- {requestMode === 'assistant' - ? `Search ${organization.name}` - : requestMode === 'plan' - ? `What should we understand and plan${firstName ? `, ${firstName}` : ''}?` - : `What should we get done${firstName ? `, ${firstName}` : ''}?`} -

-
- {composer} - {/* Anchored out of flow so expanding/collapsing never shifts the centered input */} -
- {requestMode === 'agent' ? ( - setDraft(mentionifyIntegrations(prompt))} - /> - ) : searchAccess.memberScoped ? ( - - ) : null} -
-
+ + {composer} +
+ {requestMode === 'agent' ? ( + setDraft(mentionifyIntegrations(prompt))} + /> + ) : searchAccess.memberScoped ? ( + + ) : null}
-
+ )}
) diff --git a/apps/sim/app/o/[organizationId]/search/search-results-view.tsx b/apps/sim/app/o/[organizationId]/search/search-results-view.tsx index 6a3fa1e55e8..a0d6bdd76dd 100644 --- a/apps/sim/app/o/[organizationId]/search/search-results-view.tsx +++ b/apps/sim/app/o/[organizationId]/search/search-results-view.tsx @@ -1,14 +1,9 @@ import { type ReactNode, useRef } from 'react' -import { - cn, - pageHeadingClassName, - scrollFadeAttributes, - scrollFadeClass, - useScrollEdges, -} from '@sim/emcn' +import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn' import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' import type { SearchResource } from '@/lib/mothership/generated/resources' +import { OrganizationLanding } from '@/app/o/[organizationId]/components/organization-landing' import { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results' @@ -42,11 +37,11 @@ export function SearchResultsView({ }) return (
-
-
-
{searching ? ( <> +
+
+
{composer}
@@ -73,15 +68,9 @@ export function SearchResultsView({
) : ( -
- {/* Asymmetric padding biases the group up so heading and field sit at the optical center, as on Home */} -
-

- Search {organization.name} -

-
{composer}
-
-
+ + {composer} + )}
) diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index 50bdbc978ca..88522a846df 100644 --- a/apps/sim/app/o/[organizationId]/search/search.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react' import { ComposerActionButton, toast } from '@sim/emcn' -import { ArrowUp, Loader } from '@sim/emcn/icons' +import { ArrowUp, StopFilled } from '@sim/emcn/icons' import { useIsFetching, useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' @@ -39,14 +39,13 @@ interface SearchFieldProps { function SearchField({ userId, initialValue, onSubmit }: SearchFieldProps) { const inputRef = useRef(null) const { organization } = useOrganizationContext() - const isSearching = - useIsFetching({ - queryKey: knowledgeKeys.searchQuery( - resourceScopeKey({ kind: 'organization', organizationId: organization.id }), - initialValue.trim(), - userId - ), - }) > 0 + const queryClient = useQueryClient() + const queryKey = knowledgeKeys.searchQuery( + resourceScopeKey({ kind: 'organization', organizationId: organization.id }), + initialValue.trim(), + userId + ) + const isSearching = useIsFetching({ queryKey }) > 0 const latestDraftKey = `${userId}:organization:${organization.id}:search` const latestDraft = useMothershipDraftsStore((state) => state.drafts[latestDraftKey]) const ownerQuery = initialValue || latestDraft?.searchQuery || '' @@ -99,14 +98,14 @@ function SearchField({ userId, initialValue, onSubmit }: SearchFieldProps) { submitControl={ submit()} - disabled={!canSubmit || pending} - aria-label={pending ? 'Searching' : 'Search'} + onClick={() => (pending ? void queryClient.cancelQueries({ queryKey }) : submit())} + disabled={!canSubmit} + aria-label={pending ? 'Stop search' : 'Search'} aria-busy={pending} active={canSubmit} > {pending ? ( - + ) : ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx index 77e85f9f3ed..f0ca05a08d9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx @@ -272,6 +272,32 @@ describe('search refinement with the real query cache and URL state', () => { }) describe('live search submission feedback', () => { + it('cancels a pending search without accepting its late response and allows a fresh submission', async () => { + const shape = resolveDeploymentShape() + seedDeploymentShape({ ...shape, features: { ...shape.features, liveEnterpriseSearch: true } }) + await render({ organizationPage: true, params: '?q=launch' }) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(requests).toHaveLength(1) + await act(async () => { + const stop = container.querySelector('button[aria-label="Stop search"]') + if (!stop) throw new Error('No stop control for the pending search') + stop.click() + await vi.advanceTimersByTimeAsync(1) + }) + expect(requests[0].signal.aborted).toBe(true) + await complete(0, { title: 'Cancelled result' }) + expect(container.querySelector('[data-source-link]')).toBeNull() + await act(async () => { + container.querySelector('button[aria-label="Search"]')!.click() + await vi.advanceTimersByTimeAsync(1) + }) + expect(requests).toHaveLength(2) + await complete(1, { title: 'Fresh result' }) + expect(container.querySelector('[data-source-link]')?.textContent).toBe('Fresh result') + }) + it('acknowledges the submitted query before exposing refinement controls', async () => { const shape = resolveDeploymentShape() seedDeploymentShape({ ...shape, features: { ...shape.features, liveEnterpriseSearch: true } }) @@ -286,8 +312,8 @@ describe('live search submission feedback', () => { ) ).toBe(false) expect( - container.querySelector('button[aria-label="Searching"]')?.disabled - ).toBe(true) + container.querySelector('button[aria-label="Stop search"]')?.disabled + ).toBe(false) await complete(0) expect( container.querySelector('button[aria-label="Search"]')?.disabled diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx index 6e6961afc5a..a9c43d7af35 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { Chip, + chipGeometryClass, chipHoverSurfaceClass, chipIconSlotClass, chipRadiusClass, @@ -124,29 +125,38 @@ export function SourceCard({ source, query, onSummarize, dense = false }: Source if (dense) { return ( -
- - - - +
) } From ae0b109c1f9e131cc8e48e6d5cadc8ac9b9773cf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 22:00:34 -0700 Subject: [PATCH 2/2] fix(search): keep cancellation available while editing the draft --- .../app/o/[organizationId]/search/search.tsx | 13 ++-- .../search-transitions.test.tsx | 78 +++++++++++++------ 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index 88522a846df..c7c58551404 100644 --- a/apps/sim/app/o/[organizationId]/search/search.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.tsx @@ -59,7 +59,6 @@ function SearchField({ userId, initialValue, onSubmit }: SearchFieldProps) { setDraft(draftKey, payload) setDraft(latestDraftKey, payload) } - const pending = isSearching && value.trim() === initialValue.trim() const submit = (text = value) => { if (!text.trim() || (isSearching && text.trim() === initialValue.trim())) return const { clearDraft } = useMothershipDraftsStore.getState() @@ -98,13 +97,13 @@ function SearchField({ userId, initialValue, onSubmit }: SearchFieldProps) { submitControl={ (pending ? void queryClient.cancelQueries({ queryKey }) : submit())} - disabled={!canSubmit} - aria-label={pending ? 'Stop search' : 'Search'} - aria-busy={pending} - active={canSubmit} + onClick={() => (isSearching ? void queryClient.cancelQueries({ queryKey }) : submit())} + disabled={!canSubmit && !isSearching} + aria-label={isSearching ? 'Stop search' : 'Search'} + aria-busy={isSearching} + active={canSubmit || isSearching} > - {pending ? ( + {isSearching ? ( ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx index f0ca05a08d9..e90ee758a4c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx @@ -272,31 +272,59 @@ describe('search refinement with the real query cache and URL state', () => { }) describe('live search submission feedback', () => { - it('cancels a pending search without accepting its late response and allows a fresh submission', async () => { - const shape = resolveDeploymentShape() - seedDeploymentShape({ ...shape, features: { ...shape.features, liveEnterpriseSearch: true } }) - await render({ organizationPage: true, params: '?q=launch' }) - await act(async () => { - await vi.advanceTimersByTimeAsync(1) - }) - expect(requests).toHaveLength(1) - await act(async () => { - const stop = container.querySelector('button[aria-label="Stop search"]') - if (!stop) throw new Error('No stop control for the pending search') - stop.click() - await vi.advanceTimersByTimeAsync(1) - }) - expect(requests[0].signal.aborted).toBe(true) - await complete(0, { title: 'Cancelled result' }) - expect(container.querySelector('[data-source-link]')).toBeNull() - await act(async () => { - container.querySelector('button[aria-label="Search"]')!.click() - await vi.advanceTimersByTimeAsync(1) - }) - expect(requests).toHaveLength(2) - await complete(1, { title: 'Fresh result' }) - expect(container.querySelector('[data-source-link]')?.textContent).toBe('Fresh result') - }) + it.each(['launch', 'edited draft', ''])( + 'cancels with draft %j, ignores late results, and allows a fresh submission', + async (draft) => { + const shape = resolveDeploymentShape() + seedDeploymentShape({ ...shape, features: { ...shape.features, liveEnterpriseSearch: true } }) + await render({ organizationPage: true, params: '?q=launch' }) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(requests).toHaveLength(1) + await act(async () => { + const input = container.querySelector('textarea')! + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call( + input, + draft + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + await act(async () => { + const stop = container.querySelector('button[aria-label="Stop search"]') + if (!stop) throw new Error('No stop control for the pending search') + stop.click() + await vi.advanceTimersByTimeAsync(1) + }) + expect(requests[0].signal.aborted).toBe(true) + expect(container.querySelector('textarea')!.value).toBe(draft) + await complete(0, { title: 'Cancelled result' }) + expect( + client + .getQueriesData({ queryKey: knowledgeKeys.searches() }) + .every(([, data]) => data === undefined) + ).toBe(true) + await act(async () => { + const input = container.querySelector('textarea')! + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call( + input, + 'launch' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + await act(async () => { + container.querySelector('button[aria-label="Search"]')!.click() + await vi.advanceTimersByTimeAsync(1) + }) + expect(requests).toHaveLength(2) + await complete(1, { title: 'Fresh result' }) + expect( + client + .getQueriesData({ queryKey: knowledgeKeys.searches() }) + .flatMap(([, data]) => data?.results.map((result) => result.documentId) ?? []) + ).toEqual(['Fresh result']) + } + ) it('acknowledges the submitted query before exposing refinement controls', async () => { const shape = resolveDeploymentShape()