From b8b5d57a5806325d1a1bf9b8f2666b22bd34a7ed Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 10 Sep 2026 16:55:32 -0700 Subject: [PATCH 1/2] improvement(chat): remove legacy workspace mode selector --- .../knowledge-search-results.tsx | 2 +- .../mothership-chat/mothership-chat.tsx | 35 +-- .../home/components/search-sources/index.ts | 1 - .../search-sources/search-sources.test.tsx | 177 -------------- .../search-sources/search-sources.tsx | 226 ------------------ .../suggested-actions.test.tsx | 78 +----- .../suggested-actions/suggested-actions.tsx | 81 ++----- .../components/user-input/components/index.ts | 1 - .../components/mode-switcher/index.ts | 1 - .../mode-switcher/mode-switcher.test.tsx | 222 ----------------- .../mode-switcher/mode-switcher.tsx | 68 ------ .../components/user-input/user-input.test.tsx | 109 ++------- .../home/components/user-input/user-input.tsx | 174 ++++---------- .../app/workspace/[workspaceId]/home/home.tsx | 150 +----------- .../[workspaceId]/home/hooks/chat-url.test.ts | 59 ++--- .../[workspaceId]/home/hooks/chat-url.ts | 32 +-- .../[workspaceId]/home/hooks/index.ts | 1 - .../[workspaceId]/home/hooks/use-chat.ts | 6 +- .../home/hooks/use-mothership-mode.test.tsx | 185 -------------- .../home/hooks/use-mothership-mode.ts | 48 ---- .../[workspaceId]/home/search-params.ts | 45 +--- .../components/search-source-status.test.tsx | 97 ++++++++ .../components/search-source-status.tsx | 32 ++- apps/sim/lib/posthog/events.ts | 10 +- 24 files changed, 266 insertions(+), 1574 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/search-sources/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.test.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx index 1c60964abdf..4edb1a06e76 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx @@ -99,7 +99,7 @@ type KnowledgeSearchResultsProps = ( } /** - * The composer's Search mode: the documents the signed-in person may read that + * Search results include documents the signed-in person may read that * match their query in the canonical Enterprise Search index, as rows * that open the source. A header says how many and that the search ran as * them; while a connected source is still indexing it says so, and the list diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 6fc88b74a04..07254879b4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -4,7 +4,6 @@ import type { ComponentType } from 'react' import { memo, type ReactNode, - type RefObject, useCallback, useDeferredValue, useEffect, @@ -69,14 +68,6 @@ interface MothershipChatProps { composer?: ReactNode messages: ChatMessage[] isSending: boolean - /** The composer's Search-mode results, shown above the input. */ - searchResults?: ReactNode - /** The live search query; the composer shows it so the box and the results never disagree. */ - searchQuery?: string - /** The composer, for a caller that hands a question to the agent from outside the box. */ - userInputRef?: RefObject - /** Puts the composer in the mode a queued message was written in, when one is loaded for editing. */ - onRestoreQueuedMode?: (requestMode: QueuedMessage['requestMode']) => void isReconnecting?: boolean isLoading?: boolean onSubmit: ( @@ -84,12 +75,6 @@ interface MothershipChatProps { fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[] ) => void - /** Whether the composer offers Search mode; only the Home composer answers a search. */ - canSearch?: boolean - /** Off in Search mode, where the query stays put so the person can refine it. */ - clearOnSubmit?: boolean - /** Fires when the composer's text goes from something to nothing. */ - onCleared?: () => void onStopGeneration: () => void messageQueue: QueuedMessage[] editingQueuedId: string | null @@ -341,16 +326,9 @@ export function MothershipChat({ composer, messages: messagesProp, isSending, - searchResults, - searchQuery, - userInputRef: userInputRefProp, - onRestoreQueuedMode, isReconnecting = false, isLoading = false, onSubmit, - canSearch = false, - clearOnSubmit, - onCleared, onStopGeneration, messageQueue, editingQueuedId, @@ -701,8 +679,7 @@ export function MothershipChat({ item.index !== lastIndex && item.start < (instance.scrollElement?.scrollTop ?? 0) const scrolledChatRef = useRef(UNSCROLLED) - const ownUserInputRef = useRef(null) - const userInputRef = userInputRefProp ?? ownUserInputRef + const userInputRef = useRef(null) const messageQueueRef = useRef(messageQueue) useEffect(() => { messageQueueRef.current = messageQueue @@ -726,10 +703,9 @@ export function MothershipChat({ (id: string) => { const msg = onEditQueuedMessage(id) if (!msg) return - onRestoreQueuedMode?.(msg.requestMode) userInputRef.current?.loadQueuedMessage(msg) }, - [onEditQueuedMessage, onRestoreQueuedMode, userInputRef] + [onEditQueuedMessage, userInputRef] ) const handleEditQueuedTail = useCallback(() => { @@ -860,9 +836,6 @@ export function MothershipChat({ onAnimationEnd={animateInput ? onInputAnimationEnd : undefined} >
- {searchResults && ( -
{searchResults}
- )} ({ - rows: vi.fn(), - admin: vi.fn(), - enabled: vi.fn(), - connect: vi.fn(), -})) -vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ - useOptionalWorkspaceHostContext: () => ({ features: { knowledgeMemberAccess: mocks.enabled() } }), -})) -vi.mock('@/hooks/queries/workspace', () => ({ - useWorkspacePermissionsQuery: () => ({ data: { viewer: { isAdmin: mocks.admin() } } }), -})) -vi.mock('@/hooks/use-permission-config', () => ({ - usePermissionConfig: () => ({ - integrationAvailability: new Map([ - ['slack', { oauthAvailable: true, state: 'ready' }], - ['slack_v2', { oauthAvailable: true, state: 'ready' }], - ]), - oauthServiceAvailability: new Map( - [ - 'confluence', - 'google-drive', - 'google_drive', - 'google-email', - 'google-calendar', - 'jira', - 'github-repositories', - ].map((providerId) => [providerId, true]) - ), - isIntegrationAvailabilityReady: true, - isIntegrationAvailabilityLoading: false, - integrationAvailabilityError: null, - refetchIntegrationAvailability: vi.fn(), - }), -})) -vi.mock('@/hooks/queries/kb/connectors', () => ({ - useWorkspaceMemberConnectors: () => ({ data: mocks.rows() }), - memberConnectorKeys: { list: (id: string) => ['member-connectors', id] }, -})) -vi.mock('@/hooks/use-member-enrollment', () => ({ - CONNECTABLE_MEMBERSHIPS: new Set(['invited', 'not_enrolled', 'needs_reauth']), - useMemberEnrollment: () => ({ - connectSearchSource: mocks.connect, - isAwaiting: () => false, - isAwaitingSource: () => false, - isPending: false, - setupConnector: null, - }), -})) -vi.mock('@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal', () => ({ - SourceSetupModal: () => null, -})) -vi.mock('@/lib/integrations/credential-display', () => ({ - getIntegrationsForCredentialProvider: () => [], -})) -vi.mock('@/lib/oauth', () => ({ - getCanonicalScopesForProvider: () => [], - getServiceConfigByProviderId: () => undefined, - getServiceConfigByServiceId: (id: string) => ({ providerId: id, name: id, icon: () => null }), -})) -vi.mock('@/connectors/registry', () => ({ - CONNECTOR_META_REGISTRY: Object.fromEntries( - ['confluence', 'google_drive', 'slack'].map((id) => [ - id, - { - id, - name: id, - search: true, - icon: () => null, - auth: { mode: 'oauth', provider: id }, - permissionScopedListing: { capFieldIds: [] }, - configFields: [], - }, - ]) - ), -})) - -import { SearchSources } from '@/app/workspace/[workspaceId]/home/components/search-sources/search-sources' - -let container: HTMLDivElement -let root: Root -const source = (overrides: Partial = {}): WorkspaceMemberConnector => ({ - knowledgeBaseId: 'canonical-index', - knowledgeBaseName: 'Renamed company index', - knowledgeBaseIsSearchIndex: true, - connectorId: 'source-one', - connectorType: 'confluence', - sourceDescription: 'company.atlassian.net · ENG', - memberSyncStatus: 'idle', - viewerMembership: 'not_enrolled', - viewerDocumentCount: 0, - ...overrides, -}) -function mount(rows: WorkspaceMemberConnector[]) { - mocks.rows.mockReturnValue(rows) - act(() => root.render()) -} -function chips() { - return [...container.querySelectorAll('button')] -} -beforeEach(() => { - vi.clearAllMocks() - mocks.admin.mockReturnValue(false) - mocks.enabled.mockReturnValue(true) - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - container = document.createElement('div') - document.body.append(container) - root = createRoot(container) -}) -afterEach(() => { - act(() => root.unmount()) - container.remove() -}) - -describe('home Search source connections', () => { - it('lets a reader connect a configured source after the canonical index is renamed', () => { - const connection = source() - mount([connection]) - const chip = chips().find((button) => button.textContent === 'confluence')! - expect(chip.disabled).toBe(false) - act(() => chip.click()) - expect(mocks.connect).toHaveBeenCalledWith( - 'workspace', - expect.objectContaining({ type: 'confluence' }), - connection - ) - expect(chips().find((button) => button.textContent === 'google_drive')?.disabled).toBe(true) - }) - - it('keeps distinct configured sites visible and connects only the selected source', () => { - const first = source({ viewerMembership: 'connected', viewerDocumentCount: 2 }) - const second = source({ - connectorId: 'source-two', - sourceDescription: 'other.atlassian.net · OPS', - }) - mount([first, second]) - expect(container.textContent).toContain('company.atlassian.net · ENG') - expect(container.textContent).toContain('other.atlassian.net · OPS') - const chip = chips().find((button) => button.textContent?.includes('other.atlassian.net'))! - act(() => chip.click()) - expect(mocks.connect).toHaveBeenCalledExactlyOnceWith( - 'workspace', - expect.objectContaining({ type: 'confluence' }), - second - ) - }) - - it('does not use a same-named ordinary knowledge base as the canonical index', () => { - mount([source({ knowledgeBaseIsSearchIndex: false, knowledgeBaseName: 'Sim Search' })]) - expect(chips().every((button) => button.disabled)).toBe(true) - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('does not offer stale cached connections after member access is disabled', () => { - mocks.enabled.mockReturnValue(false) - mount([source({ viewerMembership: 'connected', viewerDocumentCount: 99 })]) - expect(container.textContent).not.toContain('99 documents') - expect(chips().every((button) => button.disabled)).toBe(true) - }) - - it.each(['revoked', 'unverified_email'] as const)( - 'does not re-enroll an account with %s access', - (viewerMembership) => { - mount([source({ viewerMembership })]) - const chip = chips().find((button) => button.textContent?.startsWith('confluence'))! - expect(chip.getAttribute('aria-disabled')).toBe('true') - act(() => chip.click()) - expect(mocks.connect).not.toHaveBeenCalled() - } - ) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx deleted file mode 100644 index de1e9194d8c..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx +++ /dev/null @@ -1,226 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import { Chip, chipContentGap, cn, OverflowText } from '@sim/emcn' -import { Loader, Plus } from '@sim/emcn/icons' -import { groupSearchConnections } from '@/lib/sim-search/connections' -import { - canConnectPersonally, - SEARCH_CONNECTORS, - type SearchConnector, - searchConnectorUnavailableReason, -} from '@/lib/sim-search/connectors' -import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal' -import { BrandIcon } from '@/blocks/brand-icon' -import { - memberConnectorKeys, - useWorkspaceMemberConnectors, - type WorkspaceMemberConnector, -} from '@/hooks/queries/kb/connectors' -import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' -import { useMemberAccessAvailable } from '@/hooks/use-member-access' -import { CONNECTABLE_MEMBERSHIPS, useMemberEnrollment } from '@/hooks/use-member-enrollment' -import { usePermissionConfig } from '@/hooks/use-permission-config' - -const EMPTY_MEMBER_CONNECTORS: WorkspaceMemberConnector[] = [] - -/** The sources a person can connect themselves, alphabetical. */ -const PERSONAL_SEARCH_CONNECTORS = SEARCH_CONNECTORS.filter((connector) => - canConnectPersonally(connector.meta) -) - -/** Whether a connected source is still indexing for the viewer. */ -export function isIndexing(connection: WorkspaceMemberConnector | undefined): boolean { - return ( - connection?.viewerMembership === 'connected' && - (connection.memberSyncStatus === 'pending' || connection.memberSyncStatus === 'running') - ) -} - -/** The chip's trailing state text for one source. */ -function sourceState( - connection: WorkspaceMemberConnector | undefined, - waiting: boolean -): string | null { - if (waiting) return 'Connecting…' - if (!connection) return null - switch (connection.viewerMembership) { - case 'connected': - return isIndexing(connection) - ? 'Indexing' - : connection.viewerDocumentCount === 1 - ? '1 document' - : `${connection.viewerDocumentCount} documents` - case 'needs_reauth': - return 'Reconnect' - case 'unverified_email': - return 'Verify email' - case 'revoked': - return 'Access removed' - default: - return null - } -} - -interface SourceChipProps { - connector: SearchConnector - connection: WorkspaceMemberConnector | undefined - showSource: boolean - /** Why the source cannot be connected here, shown as the chip's title; null when it can. */ - unavailableReason: string | null - waiting: boolean - disabled: boolean - onConnect: () => void -} - -function SourceChip({ - connector, - connection, - showSource, - unavailableReason, - waiting, - disabled, - onConnect, -}: SourceChipProps) { - const state = sourceState(connection, waiting) - const connected = connection?.viewerMembership === 'connected' - const unavailable = unavailableReason !== null - const actionable = - !unavailable && - !waiting && - (!connection || CONNECTABLE_MEMBERSHIPS.has(connection.viewerMembership)) - const name = - showSource && connection?.sourceDescription - ? `${connector.meta.name} · ${connection.sourceDescription}` - : connector.meta.name - const title = unavailableReason ?? (connected ? `${name}: ${state}` : `Connect ${name}`) - const busy = waiting || isIndexing(connection) - return ( - } - rightIcon={!busy && actionable ? Plus : undefined} - rightAdornment={ - busy ? : undefined - } - > - - - {state && {state}} - - - ) -} - -interface SearchSourcesProps { - workspaceId: string -} - -/** - * Every source a person can connect themselves, as chips under the composer: - * connected ones show how many documents they can read (or that indexing is - * still running), the rest connect with one click. A source that needs a site - * or space asks for it once, in place, on the connect that creates it; - * everyone after that clicks straight through. Sources an admin must set up - * as workspace connectors do not appear here. - */ -export function SearchSources({ workspaceId }: SearchSourcesProps) { - const { integrationAvailability, oauthServiceAvailability, isIntegrationAvailabilityReady } = - usePermissionConfig() - /** With per-member access off, a connect is refused, so the chips say so instead. */ - const memberAccessAvailable = useMemberAccessAvailable() - const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId) - /** The first connect of a source turns it on for the workspace, which takes an admin. */ - const canCreate = workspacePermissions?.viewer?.isAdmin ?? false - const { data: memberConnectorRows } = useWorkspaceMemberConnectors(workspaceId, { - enabled: memberAccessAvailable, - }) - /** Rows cached before the feature went off are not this surface's to show. */ - const memberConnectors = memberAccessAvailable - ? (memberConnectorRows ?? EMPTY_MEMBER_CONNECTORS) - : EMPTY_MEMBER_CONNECTORS - const { connectionByType } = useMemo( - () => groupSearchConnections(memberConnectors), - [memberConnectors] - ) - const connectedConnectorIds = useMemo( - () => - new Set( - memberConnectors - .filter((connector) => connector.viewerMembership === 'connected') - .map((connector) => connector.connectorId) - ), - [memberConnectors] - ) - const membershipQueryKeys = useMemo(() => [memberConnectorKeys.list(workspaceId)], [workspaceId]) - const { - connectSource, - connectSearchSource, - setupConnector, - closeSetup, - isAwaiting, - isAwaitingSource, - isPending, - error, - } = useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds }) - - /** Connected sources first; the catalog is already alphabetical, so the partition keeps the order. */ - const isConnected = (connector: SearchConnector) => - connectionByType.get(connector.type)?.some((source) => source.viewerMembership === 'connected') - const ordered = [ - ...PERSONAL_SEARCH_CONNECTORS.filter(isConnected), - ...PERSONAL_SEARCH_CONNECTORS.filter((connector) => !isConnected(connector)), - ] - - return ( -
-
- {ordered.flatMap((connector) => { - const connections = connectionByType.get(connector.type) ?? [] - return (connections.length ? connections : [undefined]).map((connection) => ( - 1} - unavailableReason={searchConnectorUnavailableReason( - connector, - integrationAvailability, - { - memberAccessAvailable, - hasConnection: connection !== undefined, - canCreate, - oauthServiceAvailability, - isIntegrationAvailabilityReady, - } - )} - waiting={ - connection ? isAwaiting(connection.connectorId) : isAwaitingSource(connector.type) - } - disabled={isPending} - onConnect={() => connectSearchSource(workspaceId, connector, connection)} - /> - )) - })} -
- {error &&

{error}

} - {setupConnector && ( - - connectSource(workspaceId, setupConnector.type, sourceConfig) - } - /> - )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx index af71779f6b2..7194d0acac8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx @@ -5,22 +5,10 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCaptureEvent, modeState } = vi.hoisted(() => ({ +const { mockCaptureEvent } = vi.hoisted(() => ({ mockCaptureEvent: vi.fn(), - modeState: { initial: 'build', set: (_next: string) => {} }, })) -vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode', async () => { - const { useState } = await import('react') - return { - useMothershipMode: () => { - const [mode, setMode] = useState(modeState.initial) - modeState.set = setMode - return [mode, setMode] - }, - } -}) - vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), })) @@ -40,46 +28,9 @@ vi.mock('@/hooks/queries/tables', () => ({ vi.mock('@/hooks/queries/kb/knowledge', () => ({ useKnowledgeBasesQuery: () => ({ data: [] }), })) -vi.mock('@/app/workspace/[workspaceId]/home/components/search-sources', () => ({ - SearchSources: () =>
, -})) -vi.mock('@/hooks/use-permission-config', () => ({ - usePermissionConfig: () => ({ - integrationAvailability: new Map([['notion', { state: 'unavailable', oauthAvailable: false }]]), - }), -})) - -/** The Build-mode pool is built from the block catalog at module load; an empty catalog keeps it to the table starters. */ +/** The suggestion pool is built from the block catalog at module load; an empty catalog keeps it to the table starters. */ vi.mock('@/blocks/registry', () => ({ getAllBlockMeta: () => ({}), getAllBlocks: () => [] })) -vi.mock('@/lib/sim-search/connectors', () => { - const icon = () => null - const connector = (type: string, name: string, providerId: string) => ({ - type, - meta: { id: type, name, description: `Sync ${name}`, icon }, - providerId, - providerIds: [providerId], - requiredScopes: ['read'], - serviceName: name, - serviceIcon: icon, - blockType: type, - }) - return { - isSearchConnectorAvailable: ( - candidate: { blockType: string }, - availability: ReadonlyMap - ) => availability.get(candidate.blockType)?.oauthAvailable ?? true, - SEARCH_CONNECTORS: [ - connector('airtable', 'Airtable', 'airtable'), - connector('confluence', 'Confluence', 'confluence'), - connector('jira', 'Jira', 'jira'), - connector('jsm', 'Jira Service Management', 'jira'), - connector('notion', 'Notion', 'notion'), - connector('slack', 'Slack', 'slack'), - ], - } -}) - vi.mock('@/app/workspace/[workspaceId]/components/connect-oauth-modal', () => ({ ConnectOAuthModal: ({ open, providerId }: { open: boolean; providerId: string }) => open ?
{providerId}
: null, @@ -112,7 +63,6 @@ function rows(): HTMLButtonElement[] { beforeEach(() => { onSelectPrompt.mockClear() mockCaptureEvent.mockClear() - modeState.initial = 'build' }) afterEach(() => { @@ -123,30 +73,18 @@ afterEach(() => { }) describe('SuggestedActions', () => { - it('shows the Build starters by default', () => { + it('shows suggested actions', () => { mount() expect(heading()).toBe('Suggested actions') expect(rows().map((row) => row.textContent)).toContain('Integrate with Slack') }) - it('shows every source in Search mode instead of the sampled suggestions', () => { + it('keeps suggestion actions interactive', () => { mount() - - act(() => modeState.set('search')) - - expect(heading()).toBe('Sources') - expect(document.querySelector('[data-testid="search-sources"]')).not.toBeNull() - expect(rows()).toHaveLength(0) - }) - - it('shows the sources in Assistant mode, which answers from them', () => { - mount() - - act(() => modeState.set('assistant')) - - expect(heading()).toBe('Sources') - expect(document.querySelector('[data-testid="search-sources"]')).not.toBeNull() - expect(rows()).toHaveLength(0) + const action = rows().find((row) => row.textContent === 'Create a CRM with sample data') + expect(action).toBeDefined() + act(() => action?.click()) + expect(onSelectPrompt).toHaveBeenCalledWith('Create a CRM with sample data.') }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx index 1035e08e009..87a86f21752 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx @@ -14,15 +14,12 @@ import { } from '@/lib/integrations' import { captureEvent } from '@/lib/posthog/client' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' -import { SearchSources } from '@/app/workspace/[workspaceId]/home/components/search-sources' import type { Action, ActionIcon, OAuthConnectTarget, } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/types' import { weightedSample } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample' -import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' -import type { MothershipMode } from '@/app/workspace/[workspaceId]/home/search-params' import { BrandIcon } from '@/blocks/brand-icon' import { getAllBlockMeta } from '@/blocks/registry' import type { ModuleTag } from '@/blocks/types' @@ -30,7 +27,6 @@ import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useOAuthConnections } from '@/hooks/queries/oauth/oauth-connections' import { useTablesList } from '@/hooks/queries/tables' -import { usePermissionConfig } from '@/hooks/use-permission-config' /** Lookup integration slug by OAuth service display name (case-insensitive). */ const SLUG_BY_LOWER_NAME: ReadonlyMap = new Map( @@ -232,13 +228,6 @@ const INITIAL_ACTIONS: Action[] = [ .map(toPromptAction), ] -/** Section heading per composer mode — Search reads as a connect-your-sources list. */ -const HEADINGS: Record = { - build: 'Suggested actions', - search: 'Sources', - assistant: 'Sources', -} - interface SuggestedActionsProps { onSelectPrompt: (prompt: string) => void } @@ -246,8 +235,6 @@ interface SuggestedActionsProps { export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { const { workspaceId } = useParams<{ workspaceId: string }>() const posthog = usePostHog() - const [mode] = useMothershipMode() - const { integrationAvailability } = usePermissionConfig() const { data: credentials = EMPTY_CREDENTIALS } = useWorkspaceCredentials({ workspaceId, @@ -294,25 +281,11 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { [connectedProviders, tables.length, knowledgeBases.length] ) - /** - * Each mode's list is memoized on its own inputs alone, so switching modes — - * or the other mode's signals settling — never re-samples it. - * - * Search lists connectors to attach, and waits for the viewer's credentials: - * sampling against an empty set would list connected providers and then - * reshuffle when the query lands. Build lists personalized suggestions, - * re-sampled whenever signals resolve, and falls back to - * {@link INITIAL_ACTIONS} until the credential and service queries have loaded - * — and stays there for users with no connections — so first paint never - * flashes. The store's default mode is Build, so the server render never - * shows the sampled Search list. - */ - const buildActions = useMemo(() => { + const actions = useMemo(() => { const personalized = services.length > 0 && connectedProviders.size > 0 if (!personalized) return INITIAL_ACTIONS return computeActions(services, signals) }, [connectedProviders, services, signals]) - const actions = buildActions const handleSelect = (action: Action, position: number) => { captureEvent(posthog, 'suggested_action_clicked', { @@ -349,7 +322,7 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { aria-expanded={expanded} className='group/toggle flex w-full cursor-pointer items-center gap-2' > - {HEADINGS[mode]} + Suggested actions {/* * Revealed by hovering anywhere in the section — the group sits on the * section wrapper rather than this row, so the action rows below arm it just @@ -374,34 +347,28 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { `collapsible-up`/`-down` interpolate height alone, so a margin here would hold its full value through the close and then vanish on unmount, snapping the content below up. */} - {mode !== 'build' && workspaceId ? ( -
- -
- ) : ( -
- {actions.map((action, i) => { - const Icon = action.icon - return ( - - ) - })} -
- )} +
+ {actions.map((action, i) => { + const Icon = action.icon + return ( + + ) + })} +
{oauthTarget && workspaceId && ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts index 7d8bdca03af..95d472588c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts @@ -24,7 +24,6 @@ export { export { DropOverlay } from './drop-overlay' export { MicButton } from './mic-button' export { MicrophonePermissionHelp } from './microphone-permission-help' -export { ModeSwitcher } from './mode-switcher' export { PlusMenuDropdown } from './plus-menu-dropdown' export type { PromptEditorInstance, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/index.ts deleted file mode 100644 index 46800468812..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ModeSwitcher } from './mode-switcher' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx deleted file mode 100644 index f10c0594548..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx +++ /dev/null @@ -1,222 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from 'react' -import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockCaptureEvent, mockModeChange, mockPush, navigation } = vi.hoisted(() => ({ - mockCaptureEvent: vi.fn(), - mockModeChange: vi.fn(), - mockPush: vi.fn(), - navigation: { - pathname: '/workspace/workspace-1/home', - chatId: undefined as string | undefined, - requestMode: undefined as 'agent' | 'assistant' | undefined, - }, -})) -const mockUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() - -vi.mock('next/navigation', () => ({ - useParams: () => ({ workspaceId: 'workspace-1', chatId: navigation.chatId }), - usePathname: () => navigation.pathname, - useRouter: () => ({ push: mockPush }), -})) -/** The switcher renders only where Search mode exists, so these tests are that workspace. */ -vi.mock('@/hooks/use-member-access', () => ({ useMemberAccessAvailable: () => true })) -vi.mock('posthog-js/react', () => ({ usePostHog: () => null })) -vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent })) -vi.mock('@/hooks/queries/mothership-chats', () => ({ - useMothershipChatHistory: () => ({ - data: navigation.chatId - ? { messages: [{ role: 'user', requestMode: navigation.requestMode }] } - : undefined, - }), -})) - -import { ModeSwitcher } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher' - -let root: Root | null = null -let container: HTMLDivElement | null = null - -function mount(searchParams = '') { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - act(() => - root?.render( - - - - ) - ) -} - -function trigger(): HTMLButtonElement { - const node = container?.querySelector('button') - if (!node) throw new Error('Switcher trigger did not render') - return node -} - -/** Opens the menu the way a pointer does — Radix opens on `pointerdown`. */ -function openMenu() { - act(() => { - trigger().dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) - }) -} - -function items(): HTMLElement[] { - return Array.from(document.querySelectorAll('[role="menuitem"]')) -} - -async function select(index: number) { - await act(async () => { - items()[index].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })) - await vi.advanceTimersByTimeAsync(1) - }) -} - -beforeEach(() => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - navigation.pathname = '/workspace/workspace-1/home' - navigation.chatId = undefined - navigation.requestMode = undefined - mockPush.mockClear() - mockModeChange.mockClear() - mockCaptureEvent.mockClear() - mockUrlUpdate.mockClear() -}) - -afterEach(() => { - if (root) act(() => root?.unmount()) - container?.remove() - root = null - container = null - vi.useRealTimers() -}) - -describe('ModeSwitcher', () => { - it('renders the active mode as a label-only round chip and defaults to Build', () => { - mount() - - const button = trigger() - expect(button.textContent).toBe('Build') - expect(button.getAttribute('aria-label')).toBe('Mode: Build') - expect(button.className).toContain('h-[30px]') - expect(button.className).toContain('rounded-full') - expect(button.className).not.toContain('rounded-lg') - expect(button.className).toContain('hover-hover:bg-[var(--surface-hover)]') - expect(button.querySelector('svg')).toBeNull() - }) - - it('lists every mode and checks the active one', () => { - mount() - openMenu() - - const rows = items() - expect(rows.map((row) => row.textContent)).toEqual(['Build', 'Search', 'Assistant']) - expect(rows[0].querySelector('svg')).not.toBeNull() - expect(rows[1].querySelector('svg')).toBeNull() - expect(rows[2].querySelector('svg')).toBeNull() - }) - - it('writes the chosen mode to the URL and reports the change', async () => { - mount() - openMenu() - await select(1) - - expect(trigger().textContent).toBe('Search') - expect(mockCaptureEvent).toHaveBeenCalledWith(null, 'chat_mode_changed', { - workspace_id: 'workspace-1', - mode: 'search', - }) - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe('search') - }) - - it('reads the mode from the URL on mount', () => { - mount('?mode=assistant') - - expect(trigger().textContent).toBe('Assistant') - expect(trigger().getAttribute('aria-label')).toBe('Mode: Assistant') - }) - - it('restores Assistant from a conversation without an explicit URL mode', () => { - navigation.pathname = '/workspace/workspace-1/chat/existing-chat' - navigation.chatId = 'existing-chat' - navigation.requestMode = 'assistant' - mount() - - expect(trigger().getAttribute('aria-label')).toBe('Mode: Assistant') - expect(mockUrlUpdate).not.toHaveBeenCalled() - }) - - it('uses the explicit Assistant selection for the next turn in a Build conversation', () => { - navigation.pathname = '/workspace/workspace-1/chat/existing-chat' - navigation.chatId = 'existing-chat' - navigation.requestMode = 'agent' - mount('?mode=assistant') - - expect(trigger().getAttribute('aria-label')).toBe('Mode: Assistant') - }) - - it('changes to Build within the restored Assistant conversation', async () => { - navigation.pathname = '/workspace/workspace-1/chat/existing-chat' - navigation.chatId = 'existing-chat' - navigation.requestMode = 'assistant' - mount() - openMenu() - await select(0) - - expect(trigger().getAttribute('aria-label')).toBe('Mode: Build') - expect(mockPush).not.toHaveBeenCalled() - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe('build') - }) - - it('clears the composer and search parameters together when leaving Search', async () => { - mount('?mode=search&q=budget&source=upload&updated=7d&resource=report') - openMenu() - await select(0) - - expect(trigger().textContent).toBe('Build') - expect(mockModeChange).toHaveBeenCalledOnce() - expect(mockUrlUpdate).toHaveBeenCalledOnce() - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe( - 'mode=build&resource=report' - ) - expect(mockUrlUpdate.mock.lastCall?.[0].options).toMatchObject({ - history: 'replace', - scroll: false, - }) - expect(mockModeChange.mock.invocationCallOrder[0]).toBeLessThan( - mockUrlUpdate.mock.invocationCallOrder[0] - ) - }) - - it.each([ - ['', 2, 'assistant'], - ['?mode=assistant', 0, 'build'], - ['?mode=assistant', 1, 'search'], - ] as const)( - 'keeps the current chat when selecting a different mode', - async (params, index, target) => { - navigation.pathname = '/workspace/workspace-1/chat/existing-chat' - mount(params) - openMenu() - await select(index) - expect(mockPush).not.toHaveBeenCalled() - expect(mockModeChange).toHaveBeenCalledOnce() - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe(target) - } - ) - - it('does not report re-selecting the active mode', async () => { - mount() - openMenu() - await select(0) - - expect(trigger().textContent).toBe('Build') - expect(mockCaptureEvent).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx deleted file mode 100644 index efc97850b1f..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'use client' - -import { memo } from 'react' -import { - Chip, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuItemLabel, - DropdownMenuTrigger, -} from '@sim/emcn' -import { Check } from '@sim/emcn/icons' -import { useParams } from 'next/navigation' -import { usePostHog } from 'posthog-js/react' -import { captureEvent } from '@/lib/posthog/client' -import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' -import { - MOTHERSHIP_MODES, - type MothershipMode, -} from '@/app/workspace/[workspaceId]/home/search-params' - -const MODE_LABELS: Record = { - build: 'Build', - search: 'Search', - assistant: 'Assistant', -} - -interface ModeSwitcherProps { - onModeChange?: () => void -} - -/** - * The composer's Build / Search / Assistant switcher: a label-only `Chip` in its `round` - * shape — chip chrome throughout (`--text-body` label, `--surface-hover` on - * hover, no text-color shift), fully round to sit in the toolbar's row of - * round controls — opening a menu that checks the active mode, as - * `ChipDropdown` does. - */ -export const ModeSwitcher = memo(function ModeSwitcher({ onModeChange }: ModeSwitcherProps) { - const { workspaceId } = useParams<{ workspaceId: string }>() - const posthog = usePostHog() - const [mode, setMode] = useMothershipMode() - - const handleSelect = (next: MothershipMode) => { - if (next === mode) return - onModeChange?.() - void setMode(next) - captureEvent(posthog, 'chat_mode_changed', { workspace_id: workspaceId, mode: next }) - } - - return ( - - - - {MODE_LABELS[mode]} - - - - {MOTHERSHIP_MODES.map((option) => ( - handleSelect(option)}> - - {option === mode && } - - ))} - - - ) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx index 49262bb05fd..2db257e887f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx @@ -1,19 +1,16 @@ /** * @vitest-environment jsdom */ -import { act, createRef, useRef } from 'react' -import { useQueryState } from 'nuqs' +import { act, createRef } from 'react' import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PromptEditorInstance } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor' import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types' -const { mockSubmit, mockResetTranscript, mockMemberAccessAvailable } = vi.hoisted(() => ({ +const { mockSubmit, mockResetTranscript } = vi.hoisted(() => ({ mockSubmit: vi.fn(), mockResetTranscript: vi.fn(), - /** Search mode exists only where per-member access is on; these tests are that workspace. */ - mockMemberAccessAvailable: vi.fn(() => true), })) vi.mock('next/navigation', () => ({ @@ -23,9 +20,6 @@ vi.mock('next/navigation', () => ({ })) vi.mock('posthog-js/react', () => ({ usePostHog: () => null })) vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn() })) -vi.mock('@/hooks/use-member-access', () => ({ - useMemberAccessAvailable: () => mockMemberAccessAvailable(), -})) vi.mock('@/hooks/use-settings-navigation', () => ({ useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }), })) @@ -68,12 +62,8 @@ vi.mock('@/app/workspace/[workspaceId]/home/components/user-input/components', a const { usePromptEditor } = await import( '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' ) - const { ModeSwitcher } = await import( - '@/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher' - ) return { usePromptEditor, - ModeSwitcher, PromptEditor: ({ editor, placeholder, @@ -105,8 +95,6 @@ import { UserInput, type UserInputHandle, } from '@/app/workspace/[workspaceId]/home/components/user-input/user-input' -import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' -import { searchQueryParam } from '@/app/workspace/[workspaceId]/home/search-params' const mockUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() const QUEUED_MESSAGE: QueuedMessage = { @@ -120,34 +108,26 @@ const QUEUED_MESSAGE: QueuedMessage = { let root: Root | null = null let container: HTMLDivElement | null = null -function mount(requestMode?: QueuedMessage['requestMode']) { +function mount() { const inputRef = createRef() function Composer() { - const [mode, setMode] = useMothershipMode() - const [query] = useQueryState(searchQueryParam.key, searchQueryParam.parser) - const modes = useRef([]) - modes.current.push(`${mode}:${query ?? ''}`) return ( <> - {modes.current.join('|')} ) @@ -187,22 +167,6 @@ async function clickButton(label: string) { }) } -async function selectMode(label: string) { - const trigger = container?.querySelector('[aria-label="Mode: Search"]') - if (!trigger) throw new Error('Mode switcher did not render') - act(() => { - trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) - }) - const item = Array.from(document.querySelectorAll('[role="menuitem"]')).find( - (candidate) => candidate.textContent === label - ) - if (!item) throw new Error(`Mode ${label} did not render`) - await act(async () => { - item.click() - await vi.advanceTimersByTimeAsync(1) - }) -} - beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -219,52 +183,29 @@ afterEach(() => { vi.useRealTimers() }) -describe('search composer transitions', () => { - it.each(['Build', 'Assistant'])('clears the query when the menu selects %s', async (mode) => { +describe('workspace composer', () => { + it('keeps workspace controls and ignores legacy search-mode URLs', () => { mount() - expect(textarea().value).toBe('budget') - expect(textarea().placeholder).toBe('Search your documents…') - - await selectMode(mode) - - expect(textarea().value).toBe('') - expect(textarea().placeholder).toBe( - mode === 'Assistant' ? 'Ask about your documents or take action…' : 'Ask Sim to ' - ) - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.has('q')).toBe(false) - expect(mockSubmit).not.toHaveBeenCalled() - }) - - it.each([undefined, 'assistant'] as const)( - 'retains queued content and files after restoring request mode %s', - async (requestMode) => { - mount(requestMode) - - await clickButton('Edit queued') - - expect(textarea().value).toBe(QUEUED_MESSAGE.content) - expect(container?.querySelector('output')?.textContent).not.toContain('build:budget') - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe( - requestMode === 'assistant' - ? 'mode=assistant&resource=report' - : 'mode=build&resource=report' - ) - await clickButton('Send') - expect(mockSubmit).toHaveBeenCalledWith( - QUEUED_MESSAGE.content, - requestMode === 'assistant' ? undefined : QUEUED_MESSAGE.fileAttachments, - undefined - ) + expect(textarea().value).toBe('Initial draft') + expect(textarea().placeholder).toBe('Ask Sim to ') + expect(container?.querySelector('[aria-label^="Mode:"]')).toBeNull() + for (const label of ['Add resources', 'Attach file', 'Skills']) { + expect(container?.querySelector(`[aria-label="${label}"]`)).not.toBeNull() } - ) - - it('starts a clean composer when changing modes', async () => { - const inputRef = mount() - act(() => inputRef.current?.loadQueuedMessage({ ...QUEUED_MESSAGE, content: 'budget' })) + expect(mockUrlUpdate).not.toHaveBeenCalled() + }) - await selectMode('Build') + it('retains queued content and attachments when editing, then clears after sending', async () => { + mount() + await clickButton('Edit queued') + expect(textarea().value).toBe(QUEUED_MESSAGE.content) await clickButton('Send') - - expect(mockSubmit).toHaveBeenCalledWith('', undefined, undefined) + expect(mockSubmit).toHaveBeenCalledWith( + QUEUED_MESSAGE.content, + QUEUED_MESSAGE.fileAttachments, + undefined + ) + expect(textarea().value).toBe('') + expect(mockResetTranscript).toHaveBeenCalled() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index c76fd758d27..036b6ecc228 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -25,13 +25,11 @@ import { DropOverlay, MicButton, MicrophonePermissionHelp, - ModeSwitcher, PromptEditor, SendButton, usePromptEditor, } from '@/app/workspace/[workspaceId]/home/components/user-input/components' import { handleMothershipAddContextEvent } from '@/app/workspace/[workspaceId]/home/components/user-input/mothership-context-event' -import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' import type { FileAttachmentForApi, MothershipResource, @@ -62,21 +60,6 @@ interface UserInputProps { onStopGeneration: () => void isInitialView?: boolean onSendQueuedHead?: () => void - /** - * Whether the composer offers Search mode. Only the Home composer answers a - * search with documents; the workflow copilot always talks to the agent, so - * it must not show a mode it cannot honour. - */ - canSearch?: boolean - /** - * Whether the text is cleared once submitted. A search keeps its query in - * the box, the way a search bar does, so it can be read and refined against - * the results; a message to the agent clears, since it now lives in the - * transcript. Defaults to clearing. - */ - clearOnSubmit?: boolean - /** Called when the text becomes empty after having had content, such as a search being cleared. */ - onCleared?: () => void onEditQueuedTail?: () => void } @@ -88,8 +71,6 @@ export interface UserInputHandle { * names chip with brand icons. Focuses the input and places the caret at the * end. Does NOT submit. Safe to call with the same text twice in a row. */ populatePrompt: (text: string) => void - /** Empties the composer and its draft, as a send does; for a question handed to the agent from outside the box. */ - clear: () => void } /** @@ -107,21 +88,12 @@ const UserInputImpl = forwardRef(function UserI isInitialView = true, onSendQueuedHead, onEditQueuedTail, - canSearch = false, - clearOnSubmit = true, - onCleared, }, ref ) { const { workspaceId } = useParams<{ workspaceId: string }>() const { navigateToSettings } = useSettingsNavigation() const { userId, onContextAdd, onContextRemove } = useChatSurface() - const [mode] = useMothershipMode() - const isSearch = canSearch && mode === 'search' - const contextsEnabled = !canSearch || mode === 'build' - const contextsEnabledRef = useRef(contextsEnabled) - contextsEnabledRef.current = contextsEnabled - const [initialValue] = useState(() => { if (defaultValue) return defaultValue if (!draftScopeKey) return '' @@ -134,17 +106,16 @@ const UserInputImpl = forwardRef(function UserI const files = useFileAttachments({ userId, workspaceId, - disabled: !contextsEnabled, isLoading: isSending, }) - const hasFiles = contextsEnabled && files.attachedFiles.some((f) => !f.uploading && f.key) - const hasUploadingFiles = contextsEnabled && files.attachedFiles.some((f) => f.uploading) + const hasFiles = files.attachedFiles.some((f) => !f.uploading && f.key) + const hasUploadingFiles = files.attachedFiles.some((f) => f.uploading) const filesRef = useRef(files) filesRef.current = files const handlePasteFiles = useCallback((pasted: FileList) => { - if (contextsEnabledRef.current) filesRef.current.processFiles(pasted) + filesRef.current.processFiles(pasted) }, []) const editor = usePromptEditor({ @@ -152,7 +123,6 @@ const UserInputImpl = forwardRef(function UserI initialValue, onContextAdd, onPasteFiles: handlePasteFiles, - contextsEnabled, }) const editorRef = useRef(editor) editorRef.current = editor @@ -167,7 +137,7 @@ const UserInputImpl = forwardRef(function UserI */ useEffect(() => { const handleAddContext = (event: Event) => { - if (contextsEnabledRef.current) handleMothershipAddContextEvent(event, editorRef.current) + handleMothershipAddContextEvent(event, editorRef.current) } window.addEventListener(MOTHERSHIP_ADD_CONTEXT_EVENT, handleAddContext) @@ -176,8 +146,6 @@ const UserInputImpl = forwardRef(function UserI const draftScopeKeyRef = useRef(draftScopeKey) draftScopeKeyRef.current = draftScopeKey - const clearOnSubmitRef = useRef(clearOnSubmit) - clearOnSubmitRef.current = clearOnSubmit const hasRestoredDraftRef = useRef(false) useEffect(() => { @@ -212,8 +180,8 @@ const UserInputImpl = forwardRef(function UserI useMothershipDraftsStore.getState().clearDraft(draftScopeKey) return } - if (contextsEnabled && restoredContexts) editor.setContexts(restoredContexts) - if (contextsEnabled && restoredFiles) files.restoreAttachedFiles(restoredFiles) + if (restoredContexts) editor.setContexts(restoredContexts) + if (restoredFiles) files.restoreAttachedFiles(restoredFiles) if (caretText !== null) { const textarea = textareaRef.current if (textarea) { @@ -223,15 +191,6 @@ const UserInputImpl = forwardRef(function UserI } }, []) // eslint-disable-line react-hooks/exhaustive-deps -- intentional mount-only restore - const onClearedRef = useRef(onCleared) - onClearedRef.current = onCleared - const hadTextRef = useRef(false) - useEffect(() => { - const hasText = editor.value.trim().length > 0 - if (hadTextRef.current && !hasText) onClearedRef.current?.() - hadTextRef.current = hasText - }, [editor.value]) - const isFirstSaveRef = useRef(true) const draftSaveTimerRef = useRef(null) const pendingDraftRef = useRef<{ key: string; payload: DraftPayload } | null>(null) @@ -321,8 +280,7 @@ const UserInputImpl = forwardRef(function UserI * landing prompt panel as well as curated CTAs. Curated producers opt their * bare names in at the store seam (`storeCuratedPrompt`), so prose seeded here * is never bare-chipped (the scunthorpe constraint). - * An empty seed must not erase a queued message loaded in the same event - * that clears the search URL. The mode menu clears its query explicitly. + * An empty seed must not erase a queued message loaded for editing. */ useEffect(() => { if (defaultValue === prevDefaultValueRef.current) return @@ -390,7 +348,6 @@ const UserInputImpl = forwardRef(function UserI currentEditor.setContexts(msg.contexts ?? []) currentEditor.focusAtEnd() }, - clear: clearComposer, populatePrompt: (text: string) => { // `text` is a curated prompt, so opt its bare integration names into // `@`-mention form before chipification (the auto-mention pipeline only @@ -405,7 +362,7 @@ const UserInputImpl = forwardRef(function UserI ) const handleFileSelectStable = useCallback(() => { - if (contextsEnabledRef.current) filesRef.current.handleFileSelect() + filesRef.current.handleFileSelect() }, []) const handleFileClick = useCallback((file: AttachedFile) => { @@ -431,10 +388,6 @@ const UserInputImpl = forwardRef(function UserI const handleContainerDrop = useCallback( (e: React.DragEvent) => { - if (!contextsEnabledRef.current) { - e.preventDefault() - return - } const resourcesJson = e.dataTransfer.getData(SIM_RESOURCES_DRAG_TYPE) if (resourcesJson) { e.preventDefault() @@ -494,8 +447,8 @@ const UserInputImpl = forwardRef(function UserI }, [isSending, textareaRef]) /** - * Menu rows are excluded alongside buttons: the mode switcher's items are - * portaled, so their clicks still bubble here through the React tree. + * Portaled dialogs and menus still bubble clicks through the React tree; + * they must keep focus rather than returning it to the composer. */ const handleContainerClick = (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('button, [role="dialog"], [role="menu"]')) return @@ -523,9 +476,7 @@ const UserInputImpl = forwardRef(function UserI const currentFiles = filesRef.current const currentEditor = editorRef.current - const fileAttachmentsForApi: FileAttachmentForApi[] = ( - contextsEnabledRef.current ? currentFiles.attachedFiles : [] - ) + const fileAttachmentsForApi: FileAttachmentForApi[] = currentFiles.attachedFiles .filter((f) => !f.uploading && f.key) .map((f) => ({ id: f.id, @@ -545,12 +496,7 @@ const UserInputImpl = forwardRef(function UserI fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined, activeContexts.length > 0 ? activeContexts : undefined ) - /** - * A composer that keeps its text (Search mode) keeps its attachments and - * chips too: the search took the query alone, and the person may hand the - * rest to the agent next. - */ - if (clearOnSubmitRef.current) clearComposer() + clearComposer() }, [onSubmit, clearComposer]) /** @@ -613,27 +559,17 @@ const UserInputImpl = forwardRef(function UserI onDragOver={handleContainerDragOver} onDrop={handleContainerDrop} > - {!isSearch && mode !== 'assistant' && ( - - )} + - {contextsEnabled && ( - - )} + (function UserI
- {contextsEnabled && ( - <> - - - - - Add resources - - - - - - Attach file - - - - - - Skills - - - )} + + + + + Add resources + + + + + + Attach file + + + + + + Skills +
- {canSearch && } {isSttSupported && ( (function UserI className='hidden' accept={MOTHERSHIP_ACCEPT_ATTRIBUTE} multiple - disabled={!contextsEnabled} /> {files.isDragging && } diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 433e20142d9..62081483940 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -17,11 +17,10 @@ import { PanelLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' -import { useQueryState, useQueryStates } from 'nuqs' +import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' -import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge/search' import { LandingPromptStorage, type LandingWorkflowSeed, @@ -35,36 +34,19 @@ import { } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export' -/** - * Imported from its own folder, not the components barrel: the workflow copilot - * panel imports that barrel for the chat pieces, and a barrel edge to this - * component would drag the Sim Search connector catalog — every connector - * meta — into the workflow editor's graph. See sim-imports.md, "Code-splitting - * through barrels". - */ -import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results' import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' import { SuggestedActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions' import { useBrowserTabResources } from '@/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources' -import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref' import { resolveResourceEventPresentation, resolveResourceSelectionUpdate, } from '@/app/workspace/[workspaceId]/home/resource-view-policy' -import { - CLEARED_SEARCH_FILTERS, - type MothershipMode, - resourceParam, - resourceUrlKeys, - searchFilterParsers, - searchQueryParam, -} from '@/app/workspace/[workspaceId]/home/search-params' +import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params' import { useFolders } from '@/hooks/queries/folders' import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' import { useWorkflows } from '@/hooks/queries/workflows' import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files' -import { useMemberAccessAvailable } from '@/hooks/use-member-access' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import type { ChatContext } from '@/stores/panel' import { @@ -86,7 +68,6 @@ import type { FileAttachmentForApi, MothershipResource, MothershipResourceType, - QueuedMessage, WorkspaceResourceRef, } from './types' @@ -166,27 +147,9 @@ export function Home({ chatId, userName, userId }: HomeProps) { const posthogRef = useRef(posthog) posthogRef.current = posthog const [initialPrompt, setInitialPrompt] = useState('') - /** The search query lives in the URL so a search is a shareable link; null between searches. */ - const [searchQueryValue, setSearchQueryParam] = useQueryState(searchQueryParam.key, { - ...searchQueryParam.parser, - ...resourceUrlKeys, - }) - const searchQuery = searchQueryValue ?? '' - const [, setSearchFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys) - /** A new or cleared query starts from unfiltered results. */ - const setSearchQuery = useCallback( - (query: string) => { - void setSearchQueryParam(query || null) - void setSearchFilters(CLEARED_SEARCH_FILTERS) - }, - [setSearchQueryParam, setSearchFilters] - ) - const memberAccessAvailable = useMemberAccessAvailable() - const [composerMode, setComposerMode] = useMothershipMode() const hasCheckedLandingStorageRef = useRef(false) const initialViewInputRef = useRef(null) const initialViewUserInputRef = useRef(null) - const chatViewUserInputRef = useRef(null) const [isInputEntering, setIsInputEntering] = useState(false) @@ -470,35 +433,10 @@ export function Home({ chatId, userName, userId }: HomeProps) { }, [workspaceId, getCurrentRequestId, stopGeneration]) const handleSubmit = useCallback( - async ( - text: string, - fileAttachments?: FileAttachmentForApi[], - contexts?: ChatContext[], - modeOverride?: MothershipMode, - assistantSearch?: WorkspaceSearchFilters - ) => { + async (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return - /** - * Search lists documents, not a turn of the agent, and only a query can - * be searched: attachments alone have nothing to search for. Assistant - * makes the query a turn of the agent grounded in the sources. - * - * The override skips `useMothershipMode`, so the gate is applied again - * where the mode is consumed: both modes answer from the workspace's - * indexed sources, and neither is offered where those do not exist. - */ - const requestedMode = modeOverride ?? composerMode - const mode = requestedMode !== 'build' && !memberAccessAvailable ? 'build' : requestedMode - const answering = mode === 'assistant' - if (mode === 'search') { - /** A search sends nothing, so an edit in progress is released rather than left waiting. */ - if (editingQueuedId) cancelQueueEdit() - if (trimmed) setSearchQuery(trimmed) - return - } - captureEvent(posthogRef.current, 'task_message_sent', { workspace_id: workspaceId, has_attachments: !!(fileAttachments && fileAttachments.length > 0), @@ -511,65 +449,11 @@ export function Home({ chatId, userName, userId }: HomeProps) { } prepareResourceViewForAgentTurn() - sendMessage( - trimmed || 'Analyze the attached file(s).', - fileAttachments, - contexts, - answering ? { requestMode: 'assistant', assistantSearch } : undefined - ) - }, - [ - workspaceId, - chatId, - composerMode, - memberAccessAvailable, - editingQueuedId, - cancelQueueEdit, - prepareResourceViewForAgentTurn, - sendMessage, - setSearchQuery, - ] - ) - - /** - * A queued message re-enters the composer in the mode it was written in: an - * Assistant question edits as an Assistant question, and never as a Search, - * which submits nothing and would leave the edit stranded. - */ - const restoreQueuedMode = useCallback( - (requestMode: QueuedMessage['requestMode']) => { - void setComposerMode(requestMode === 'assistant' ? 'assistant' : 'build') + sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts) }, - [setComposerMode] + [workspaceId, chatId, prepareResourceViewForAgentTurn, sendMessage] ) - /** An emptied search box returns to the sources; a send in any other mode has no search to clear. */ - const clearSearch = useCallback(() => { - if (searchQueryValue !== null) setSearchQuery('') - }, [searchQueryValue, setSearchQuery]) - - /** - * Summarize or Answer on a result: switch to Assistant and hand the question - * to it. The submit reads the mode from this render, so it is sent as an - * Assistant turn directly rather than waiting for the URL to update, and the - * box is emptied as a send empties it, so the query does not linger as a - * draft under the answer. - */ - const handleSummarize = async (prompt: string, assistantSearch: WorkspaceSearchFilters) => { - await setComposerMode('assistant') - initialViewUserInputRef.current?.clear() - chatViewUserInputRef.current?.clear() - void handleSubmit(prompt, undefined, undefined, 'assistant', assistantSearch) - } - const showSearchResults = composerMode === 'search' && searchQuery.trim().length > 0 - const searchResults = showSearchResults ? ( - - ) : null - /** * Handles cross-surface send requests (terminal/console "Fix in Chat", the * log "Troubleshoot in Chat" action). `preventDefault` claims the event so a @@ -789,25 +673,20 @@ export function Home({ chatId, userName, userId }: HomeProps) { > {/* Anchored out of flow so expanding/collapsing never shifts the centered input */}
- {searchResults ?? ( - - initialViewUserInputRef.current?.populatePrompt(prompt) - } - /> - )} + + initialViewUserInputRef.current?.populatePrompt(prompt) + } + />
@@ -817,16 +696,9 @@ export function Home({ chatId, userName, userId }: HomeProps) { workspaceId={workspaceId} messages={messages} isSending={isSending} - searchResults={searchResults} - searchQuery={searchQuery} - userInputRef={chatViewUserInputRef} - onRestoreQueuedMode={restoreQueuedMode} isReconnecting={isReconnecting} isLoading={showChatSkeleton} onSubmit={handleSubmit} - canSearch={memberAccessAvailable} - clearOnSubmit={composerMode !== 'search'} - onCleared={clearSearch} onStopGeneration={handleStopGeneration} messageQueue={messageQueue} editingQueuedId={editingQueuedId} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.test.ts index 3c59c521738..820d5351ae6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.test.ts @@ -4,53 +4,26 @@ import { describe, expect, it } from 'vitest' import { chatUrl } from '@/app/workspace/[workspaceId]/home/hooks/chat-url' -function withSearch(search: string) { - window.history.replaceState(null, '', `/workspace/ws-1/home${search}`) -} - describe('chatUrl', () => { - it('routes organization conversations to their owner without inventing a workspace', () => { - window.history.replaceState(null, '', '/o/org-1/home') - expect(chatUrl({ organizationId: 'org-1' }, 'chat-1', 'assistant')).toBe( - '/o/org-1/chat/chat-1?mode=assistant' - ) - }) - - it('carries the mode and the open resource onto the chat path', () => { - withSearch('?mode=assistant&resource=res-1') - expect(chatUrl('ws-1', 'chat-1')).toBe( - '/workspace/ws-1/chat/chat-1?mode=assistant&resource=res-1' - ) + it('routes organization conversations without adding a workspace or mode', () => { + window.history.replaceState(null, '', '/o/org-1/home?mode=assistant') + expect(chatUrl({ organizationId: 'org-1' }, 'chat-1')).toBe('/o/org-1/chat/chat-1') }) - it('leaves a search query and its filters behind', () => { - withSearch('?q=volvo&source=gmail&updated=7d&mode=assistant') - expect(chatUrl('ws-1', 'chat-1')).toBe('/workspace/ws-1/chat/chat-1?mode=assistant') - }) + it.each(['build', 'assistant', 'search', 'unknown'])( + 'preserves the resource while dropping legacy mode %s and search filters', + (mode) => { + window.history.replaceState( + null, + '', + `/workspace/ws-1/home?mode=${mode}&q=budget&source=upload&updated=7d&resource=report` + ) + expect(chatUrl('ws-1', 'chat-1')).toBe('/workspace/ws-1/chat/chat-1?resource=report') + } + ) - it('produces a clean path when nothing belongs on the chat', () => { - withSearch('?q=volvo') + it('produces a clean path when no resource is selected', () => { + window.history.replaceState(null, '', '/workspace/ws-1/home?q=budget') expect(chatUrl('ws-1', 'chat-1')).toBe('/workspace/ws-1/chat/chat-1') }) - - it('uses the submitted mode only when no view has been selected', () => { - withSearch('') - expect(chatUrl('ws-1', 'chat-1', 'assistant')).toBe( - '/workspace/ws-1/chat/chat-1?mode=assistant' - ) - expect(chatUrl('ws-1', 'chat-1', 'agent')).toBe('/workspace/ws-1/chat/chat-1?mode=build') - }) - - it.each([ - ['?mode=build', 'assistant', '?mode=build'], - ['?mode=assistant', 'agent', '?mode=assistant'], - [ - '?mode=search&q=budget&source=upload&updated=7d', - 'assistant', - '?mode=search&q=budget&source=upload&updated=7d', - ], - ] as const)('preserves a mode selected after submission: %s', (current, submitted, expected) => { - withSearch(current) - expect(chatUrl('ws-1', 'chat-1', submitted)).toBe(`/workspace/ws-1/chat/chat-1${expected}`) - }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts index 3f02aec4e30..384dd2f1dd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts @@ -1,33 +1,11 @@ -import { - modeParam, - resourceParam, - searchFilterParsers, - searchQueryParam, -} from '@/app/workspace/[workspaceId]/home/search-params' +import { resourceParam } from '@/app/workspace/[workspaceId]/home/search-params' -/** - * Preserve the view selected while a new chat was starting. The submitted turn - * supplies a fallback only; it cannot overwrite a subsequent mode switch. - */ -export function chatUrl( - owner: string | { organizationId: string }, - chatId: string, - requestMode?: 'agent' | 'assistant' -): string { +/** Preserve the selected resource when a new conversation receives its chat URL. */ +export function chatUrl(owner: string | { organizationId: string }, chatId: string): string { const current = new URLSearchParams(window.location.search) const carried = new URLSearchParams() - const mode = - modeParam.parser.parse(current.get(modeParam.key) ?? '') ?? - (requestMode === 'assistant' ? 'assistant' : requestMode === 'agent' ? 'build' : null) - if (mode) carried.set(modeParam.key, mode) - const keys = - mode === 'search' - ? [resourceParam.key, searchQueryParam.key, ...Object.keys(searchFilterParsers)] - : [resourceParam.key] - for (const key of keys) { - const value = current.get(key) - if (value) carried.set(key, value) - } + const resource = current.get(resourceParam.key) + if (resource) carried.set(resourceParam.key, resource) const search = carried.toString() const basePath = typeof owner === 'string' ? `/workspace/${owner}` : `/o/${owner.organizationId}` return `${basePath}/chat/${chatId}${search ? `?${search}` : ''}` diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts index e6afef301d8..8c1fa13edd3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts @@ -5,5 +5,4 @@ export { shouldActivateResourceEvent, useChat, } from './use-chat' -export { useMothershipMode } from './use-mothership-mode' export { useMothershipResize } from './use-mothership-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 0cafff340c1..1af0c19b022 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1873,11 +1873,7 @@ export function useChat( window.history.replaceState( null, '', - chatUrl( - organizationId ? { organizationId } : workspaceId!, - chatId, - activeTurn?.optimisticUserMessage.requestMode - ) + chatUrl(organizationId ? { organizationId } : workspaceId!, chatId) ) } if (options?.invalidateList) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx deleted file mode 100644 index 4b789cb1d92..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx +++ /dev/null @@ -1,185 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from 'react' -import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { MothershipMode } from '@/app/workspace/[workspaceId]/home/search-params' - -const { mockMemberAccessAvailable, history } = vi.hoisted(() => ({ - mockMemberAccessAvailable: vi.fn(() => true), - history: { - messages: [] as { role: 'user' | 'assistant'; requestMode?: 'agent' | 'assistant' }[], - }, -})) -const mockUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() - -vi.mock('@/hooks/use-member-access', () => ({ - useMemberAccessAvailable: () => mockMemberAccessAvailable(), -})) -vi.mock('next/navigation', () => ({ - useParams: () => ({ workspaceId: 'workspace-1', chatId: 'chat-1' }), - usePathname: () => '/workspace/workspace-1/home', - useRouter: () => ({ push: vi.fn() }), -})) -vi.mock('@/hooks/queries/mothership-chats', () => ({ - useMothershipChatHistory: () => ({ data: history }), -})) - -import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' - -let root: Root | null = null -let container: HTMLDivElement | null = null -let current: ReturnType | null = null - -function Probe() { - current = useMothershipMode() - return null -} - -function mount(searchParams = '') { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - navigate(searchParams) -} - -function navigate(searchParams: string) { - act(() => - root?.render( - - - - ) - ) -} - -function mode(): MothershipMode { - if (!current) throw new Error('Probe did not render') - return current[0] -} - -/** nuqs batches its URL write onto a timeout, so a write is read back after the tick. */ -async function setMode(next: MothershipMode) { - await act(async () => { - current?.[1](next) - await vi.advanceTimersByTimeAsync(1) - }) -} - -beforeEach(() => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - mockMemberAccessAvailable.mockReturnValue(true) - history.messages = [] - mockUrlUpdate.mockClear() -}) - -afterEach(() => { - if (root) act(() => root?.unmount()) - container?.remove() - root = null - container = null - current = null - vi.useRealTimers() -}) - -/** - * The mode's ordinary read/write behavior is covered through the UI in - * `mode-switcher.test.tsx`; one write stands here as the control the - * per-member-access cases are read against. - */ -describe('useMothershipMode', () => { - it('writes the chosen mode to the URL', async () => { - mount() - await setMode('search') - - expect(mode()).toBe('search') - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe('search') - }) - - it.each([ - ['agent', 'assistant', 'assistant'], - ['assistant', 'agent', 'build'], - ] as const)('resumes the latest user mode, %s then %s', (first, last, expected) => { - history.messages = [ - { role: 'user', requestMode: first }, - { role: 'user', requestMode: last }, - { role: 'assistant', requestMode: first }, - ] - mount() - expect(mode()).toBe(expected) - expect(mockUrlUpdate).not.toHaveBeenCalled() - }) - - it.each(['build', 'search', 'assistant'] as const)( - 'respects explicit URL mode %s on reload', - (explicit) => { - history.messages = [{ role: 'user', requestMode: 'assistant' }] - mount(`?mode=${explicit}`) - expect(mode()).toBe(explicit) - } - ) - - it('opens a query-only link in Search without writing a mode or message', () => { - mount('?q=budget') - expect(mode()).toBe('search') - expect(mockUrlUpdate).not.toHaveBeenCalled() - }) - - it('keeps explicit Build even when the URL also contains a query', () => { - mount('?mode=build&q=budget') - expect(mode()).toBe('build') - }) - - it('follows back and forward URL changes without overriding them from history', () => { - history.messages = [{ role: 'user', requestMode: 'assistant' }] - mount('?mode=build') - expect(mode()).toBe('build') - navigate('?mode=search&q=budget') - expect(mode()).toBe('search') - navigate('?mode=build') - expect(mode()).toBe('build') - navigate('') - expect(mode()).toBe('assistant') - expect(mockUrlUpdate).not.toHaveBeenCalled() - }) - - it('keeps the selected mode when an earlier in-flight turn finishes persisting', async () => { - history.messages = [{ role: 'user', requestMode: 'agent' }] - mount() - await setMode('search') - history.messages = [...history.messages, { role: 'user', requestMode: 'assistant' }] - navigate('?mode=search') - expect(mode()).toBe('search') - }) - - describe('without per-member access', () => { - beforeEach(() => { - mockMemberAccessAvailable.mockReturnValue(false) - }) - - it('reads Build from a link naming a mode the workspace does not have', () => { - mount('?mode=search') - - expect(mode()).toBe('build') - }) - - it('writes no mode the workspace does not have', async () => { - mount() - await setMode('search') - - expect(mode()).toBe('build') - expect(mockUrlUpdate).not.toHaveBeenCalled() - }) - - it('still returns to Build, so a stale link can be left', async () => { - mount('?mode=search&q=budget') - await setMode('build') - - expect(mode()).toBe('build') - expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe('mode=build') - }) - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts deleted file mode 100644 index 13af3aa9a2c..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts +++ /dev/null @@ -1,48 +0,0 @@ -'use client' - -import { useCallback } from 'react' -import { useParams } from 'next/navigation' -import { useQueryStates } from 'nuqs' -import { - CLEARED_SEARCH_FILTERS, - composerModeParsers, - type MothershipMode, - resourceUrlKeys, -} from '@/app/workspace/[workspaceId]/home/search-params' -import { useMothershipChatHistory } from '@/hooks/queries/mothership-chats' -import { useMemberAccessAvailable } from '@/hooks/use-member-access' - -/** - * URL selection owns the current view and next turn. A bare chat link resumes - * the latest persisted user mode without changing the mode of any active run. - */ -export function useMothershipMode() { - const memberAccessAvailable = useMemberAccessAvailable() - const { chatId } = useParams<{ chatId?: string }>() - const [{ mode: urlMode, q: query }, setParams] = useQueryStates( - composerModeParsers, - resourceUrlKeys - ) - const { data: chatHistory } = useMothershipChatHistory(chatId) - let persistedMode: 'agent' | 'assistant' | undefined - for (const message of chatHistory?.messages ?? []) { - if (message.role === 'user') persistedMode = message.requestMode - } - const mode = - urlMode ?? (query?.trim() ? 'search' : persistedMode === 'assistant' ? 'assistant' : 'build') - const setMode = useCallback( - async (next: MothershipMode) => { - if (next !== 'build' && !memberAccessAvailable) return - return setParams( - { - mode: next, - ...(next === 'search' ? {} : { q: null, ...CLEARED_SEARCH_FILTERS }), - }, - { history: 'replace', scroll: false } - ) - }, - [setParams, memberAccessAvailable] - ) - - return [memberAccessAvailable ? mode : 'build', setMode] as const -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts index a96d94d71d3..19401fa6e00 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts @@ -26,36 +26,6 @@ export const resourceUrlKeys = { clearOnDefault: true, } as const -/** - * `q` is the composer's Search-mode query, so a search is a shareable, - * bookmarkable link. Present only while a search is showing: it is dropped - * when the box empties, on Summarize, and when the mode leaves Search. The - * composer reads it once on mount to restore the query and the Search mode. - * Filter-like, so it replaces the history entry. - */ -export const searchQueryParam = { - key: 'q', - parser: parseAsString, -} as const - -/** The composer's modes: the agent, enterprise search, or the assistant answering from the sources. */ -export const MOTHERSHIP_MODES = ['build', 'search', 'assistant'] as const - -export type MothershipMode = (typeof MOTHERSHIP_MODES)[number] - -/** - * `mode` is the composer's mode, so a refresh, back, forward, or shared link - * lands in the same mode. A missing value falls back to the latest user turn; - * an explicit Build selection stays in the URL to distinguish it from that fallback. - */ -export const modeParam = { - key: 'mode', - parser: parseAsStringLiteral(MOTHERSHIP_MODES).withOptions({ - history: 'replace', - clearOnDefault: true, - }), -} as const - /** The recency windows a search can be narrowed to. */ export const UPDATED_WINDOWS = [ { id: 'any', label: 'Any time', days: null }, @@ -65,21 +35,10 @@ export const UPDATED_WINDOWS = [ const UPDATED_WINDOW_IDS = UPDATED_WINDOWS.map((window) => window.id) /** - * The result filters, beside `q`, so a narrowed search is the same shareable - * link as the search itself. `source` is a connector type or `upload`, absent - * for every source; both are dropped with the query. + * Shared result filters for organization search. `source` is a connector type + * or `upload`, absent for every source. */ export const searchFilterParsers = { source: parseAsString, updated: parseAsStringLiteral(UPDATED_WINDOW_IDS).withDefault('any'), } as const - -/** Every search param at its default: what leaving a search writes. */ -export const CLEARED_SEARCH_FILTERS = { source: null, updated: null } as const - -/** A mode transition clears its search query and filters in the same URL update. */ -export const composerModeParsers = { - [modeParam.key]: modeParam.parser, - [searchQueryParam.key]: searchQueryParam.parser, - ...searchFilterParsers, -} as const diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.test.tsx new file mode 100644 index 00000000000..555d3468c92 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.test.tsx @@ -0,0 +1,97 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ResourceScope } from '@/lib/core/resource-scope' +import { SearchSourceStatus } from '@/app/workspace/[workspaceId]/search/components/search-source-status' + +const { push, host } = vi.hoisted(() => ({ push: vi.fn(), host: vi.fn() })) +vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) })) +vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ + useOptionalWorkspaceHostContext: host, +})) +vi.mock('@/connectors/registry', () => ({ CONNECTOR_META_REGISTRY: {} })) +vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section', () => ({ + ConnectorsSection: () => null, +})) +vi.mock('@sim/emcn', () => { + const Container = ({ children }: { children: ReactNode }) =>
{children}
+ return { + ChipModal: Container, + ChipModalBody: Container, + ChipModalField: Container, + ChipModalHeader: Container, + ChipModalFooter: ({ + primaryAction, + }: { + primaryAction: { label: string; onClick: () => void } + }) => ( + + ), + } +}) + +describe('SearchSourceStatus navigation', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + root = createRoot(container) + push.mockClear() + host.mockReturnValue(null) + }) + + afterEach(() => act(() => root.unmount())) + + function render(scope: ResourceScope) { + act(() => + root.render( + + ) + ) + } + + it('opens the owning organization search route', () => { + render({ kind: 'organization', organizationId: 'org-1' }) + act(() => container.querySelector('button')?.click()) + expect(push).toHaveBeenCalledWith('/o/org-1/search') + }) + + it.each([ + ['workspace-1', true, true, true, true], + ['workspace-1', false, true, true, false], + ['workspace-1', true, false, true, false], + ['workspace-1', true, true, false, false], + ['another-workspace', true, true, true, false], + ])( + 'gates workspace entry using routed host access: %s %s %s %s', + (id, isMember, organizationSearch, knowledgeMemberAccess, visible) => { + host.mockReturnValue({ + workspace: { id }, + hostOrganizationId: 'org-1', + viewer: { isHostOrganizationMember: isMember }, + features: { organizationSearch, knowledgeMemberAccess }, + }) + render({ kind: 'workspace', workspaceId: 'workspace-1' }) + const button = container.querySelector('button') + expect(Boolean(button)).toBe(visible) + if (visible) { + act(() => button?.click()) + expect(push).toHaveBeenCalledWith('/o/org-1/search') + } + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx index 778f7227fd0..b9f2ecb2c14 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx @@ -11,6 +11,7 @@ import { useRouter } from 'next/navigation' import type { ResourceScope } from '@/lib/core/resource-scope' import { organizationRoutes } from '@/lib/navigation/paths' import { ConnectorsSection } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import type { ConnectorData } from '@/hooks/queries/kb/connectors' @@ -33,6 +34,16 @@ export function SearchSourceStatus({ onClose, }: SearchSourceStatusProps) { const router = useRouter() + const host = useOptionalWorkspaceHostContext() + const organizationId = + scope.kind === 'organization' + ? scope.organizationId + : host?.workspace.id === scope.workspaceId && + host.viewer.isHostOrganizationMember && + host.features?.organizationSearch && + host.features.knowledgeMemberAccess + ? host.hostOrganizationId + : null const title = `${CONNECTOR_META_REGISTRY[connectorType]?.name ?? 'Source'} sources` return ( - - router.push( - scope.kind === 'organization' - ? organizationRoutes(scope.organizationId).search - : `/workspace/${scope.workspaceId}/home?mode=search` - ), - }} - /> + {organizationId && ( + router.push(organizationRoutes(organizationId).search), + }} + /> + )} ) } diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index e9ccbf261f1..1b964c1ce0f 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -620,19 +620,13 @@ export interface PostHogEventMap { action_id?: string } - /** The chat composer's mode switcher picked a different mode. */ - chat_mode_changed: { - workspace_id: string - mode: 'build' | 'search' | 'assistant' - } - /** * A home-page suggested action was clicked. `action_id` is the candidate id - * (e.g. `gmail-0`); `connector` rows are the Search-mode "Connect X" rows. + * (e.g. `integrate-gmail`). */ suggested_action_clicked: { workspace_id: string - kind: 'prompt' | 'integration' | 'connector' + kind: 'prompt' | 'integration' action_id: string label: string position: number From b85c6b01869219e19f420cf80b98bb90c9827cac Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 10 Sep 2026 17:05:23 -0700 Subject: [PATCH 2/2] fix(chat): preserve request options when editing queued messages --- .../home/hooks/use-chat.mount-send.test.tsx | 35 +++++++++++++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 8 ++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index eb33c508b74..7ec24fb8476 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -339,6 +339,41 @@ describe('useChat remount send recovery', () => { vi.clearAllMocks() }) + it.each([ + { options: undefined, expectedSource: 'drive' }, + { options: { assistantSearch: { source: 'slack' } }, expectedSource: 'slack' }, + ])( + 'preserves queued assistant mode when edited with $options', + async ({ options, expectedSource }) => { + useMothershipQueueStore.setState({ + queues: { + 'chat-a': [ + { + id: 'queued-question', + content: 'Find the policy', + requestMode: 'assistant', + assistantSearch: { source: 'drive' }, + }, + ], + }, + editing: { 'chat-a': 'queued-question' }, + }) + const { getResult } = renderUseChatInChat('chat-a') + + await act(async () => { + await getResult().sendMessage('Find the updated policy', undefined, undefined, options) + }) + await waitFor(() => state.postBodies.length === 1) + + expect(state.postBodies[0]).toMatchObject({ + message: 'Find the updated policy', + mode: 'assistant', + assistantSearch: { source: expectedSource }, + }) + expect(useMothershipQueueStore.getState().editing['chat-a']).toBeUndefined() + } + ) + it('keeps a cross-route handoff recoverable across a StrictMode double-mount', async () => { MothershipHandoffStorage.store({ message: 'investigate this failed run' }, 'ws-1') diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 1af0c19b022..7c53e9e7892 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -4396,14 +4396,14 @@ export function useChat( // Edit-in-place: replace at the original index. If the slot was already // dispatched mid-edit (UI-guard race), fall through to a tail-append. if (editingId) { - const existing = queueStore.queues[activeChatKey] ?? [] - if (existing.some((m) => m.id === editingId)) { + const existing = queueStore.queues[activeChatKey]?.find((m) => m.id === editingId) + if (existing) { queueStore.replaceAt(activeChatKey, editingId, { content: message, fileAttachments, contexts, - requestMode: options?.requestMode, - assistantSearch: options?.assistantSearch, + requestMode: options?.requestMode ?? existing.requestMode, + assistantSearch: options?.assistantSearch ?? existing.assistantSearch, }) queueStore.setEditing(activeChatKey, null) // Resume dispatch if it paused on this slot.