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..c7c58551404 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 || '' @@ -60,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() @@ -99,14 +97,14 @@ function SearchField({ userId, initialValue, onSubmit }: SearchFieldProps) { submitControl={ submit()} - disabled={!canSubmit || pending} - aria-label={pending ? 'Searching' : '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 77e85f9f3ed..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,6 +272,60 @@ describe('search refinement with the real query cache and URL state', () => { }) describe('live search submission feedback', () => { + 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() seedDeploymentShape({ ...shape, features: { ...shape.features, liveEnterpriseSearch: true } }) @@ -286,8 +340,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 ( -
- - - - +
) }