Skip to content

Commit af2557b

Browse files
committed
improvement(home): keep the composer mode in the URL and drop the mode store
1 parent d0744a8 commit af2557b

13 files changed

Lines changed: 147 additions & 109 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,23 @@ import { act } from 'react'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockCaptureEvent } = vi.hoisted(() => ({
8+
const { mockCaptureEvent, modeState } = vi.hoisted(() => ({
99
mockCaptureEvent: vi.fn(),
10+
/** The URL `mode` param as the nuqs mock serves it; `set` is the live setter once mounted. */
11+
modeState: { initial: 'build', set: (_next: string) => {} },
1012
}))
1113

14+
vi.mock('nuqs', async () => {
15+
const { useState } = await import('react')
16+
return {
17+
useQueryState: () => {
18+
const [mode, setMode] = useState(modeState.initial)
19+
modeState.set = setMode
20+
return [mode, setMode]
21+
},
22+
}
23+
})
24+
1225
vi.mock('next/navigation', () => ({
1326
useParams: () => ({ workspaceId: 'workspace-1' }),
1427
}))
@@ -74,7 +87,6 @@ vi.mock('@/app/workspace/[workspaceId]/components/connect-oauth-modal', () => ({
7487
}))
7588

7689
import { SuggestedActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions'
77-
import { useMothershipModeStore } from '@/stores/mothership-mode/store'
7890

7991
let root: Root | null = null
8092
let container: HTMLDivElement | null = null
@@ -101,7 +113,7 @@ function rows(): HTMLButtonElement[] {
101113
beforeEach(() => {
102114
onSelectPrompt.mockClear()
103115
mockCaptureEvent.mockClear()
104-
useMothershipModeStore.getState().reset()
116+
modeState.initial = 'build'
105117
})
106118

107119
afterEach(() => {
@@ -122,7 +134,7 @@ describe('SuggestedActions', () => {
122134
it('shows every source in Search mode instead of the sampled suggestions', () => {
123135
mount()
124136

125-
act(() => useMothershipModeStore.getState().setMode('search'))
137+
act(() => modeState.set('search'))
126138

127139
expect(heading()).toBe('Sources')
128140
expect(document.querySelector('[data-testid="search-sources"]')).not.toBeNull()
@@ -132,7 +144,7 @@ describe('SuggestedActions', () => {
132144
it('shows the sources in Assistant mode, which answers from them', () => {
133145
mount()
134146

135-
act(() => useMothershipModeStore.getState().setMode('assistant'))
147+
act(() => modeState.set('assistant'))
136148

137149
expect(heading()).toBe('Sources')
138150
expect(document.querySelector('[data-testid="search-sources"]')).not.toBeNull()

apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import type {
2121
OAuthConnectTarget,
2222
} from '@/app/workspace/[workspaceId]/home/components/suggested-actions/types'
2323
import { weightedSample } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample'
24+
import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode'
25+
import type { MothershipMode } from '@/app/workspace/[workspaceId]/home/search-params'
2426
import { BrandIcon } from '@/blocks/brand-icon'
2527
import { getAllBlockMeta } from '@/blocks/registry'
2628
import type { ModuleTag } from '@/blocks/types'
@@ -29,7 +31,6 @@ import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
2931
import { useOAuthConnections } from '@/hooks/queries/oauth/oauth-connections'
3032
import { useTablesList } from '@/hooks/queries/tables'
3133
import { usePermissionConfig } from '@/hooks/use-permission-config'
32-
import { type MothershipMode, useMothershipModeStore } from '@/stores/mothership-mode/store'
3334

3435
/** Lookup integration slug by OAuth service display name (case-insensitive). */
3536
const SLUG_BY_LOWER_NAME: ReadonlyMap<string, string> = new Map(
@@ -245,7 +246,7 @@ interface SuggestedActionsProps {
245246
export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
246247
const { workspaceId } = useParams<{ workspaceId: string }>()
247248
const posthog = usePostHog()
248-
const mode = useMothershipModeStore((state) => state.mode)
249+
const [mode] = useMothershipMode()
249250
const { integrationAvailability } = usePermissionConfig()
250251

251252
const { data: credentials = EMPTY_CREDENTIALS } = useWorkspaceCredentials({

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,35 @@ import { act } from 'react'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockCaptureEvent, mockSetSearchQuery, mockSetSearchFilters } = vi.hoisted(() => ({
9-
mockCaptureEvent: vi.fn(),
10-
mockSetSearchQuery: vi.fn(),
11-
mockSetSearchFilters: vi.fn(),
12-
}))
8+
const { mockCaptureEvent, mockSetSearchQuery, mockSetSearchFilters, modeState } = vi.hoisted(
9+
() => ({
10+
mockCaptureEvent: vi.fn(),
11+
mockSetSearchQuery: vi.fn(),
12+
mockSetSearchFilters: vi.fn(),
13+
/** The URL `mode` param as the nuqs mock serves it; `set` is the live setter once mounted. */
14+
modeState: { initial: 'build', set: (_next: string) => {} },
15+
})
16+
)
1317

1418
vi.mock('next/navigation', () => ({
1519
useParams: () => ({ workspaceId: 'workspace-1' }),
1620
}))
17-
vi.mock('nuqs', () => ({
18-
useQueryState: () => [null, mockSetSearchQuery],
19-
useQueryStates: () => [{}, mockSetSearchFilters],
20-
}))
21+
vi.mock('nuqs', async () => {
22+
const { useState } = await import('react')
23+
return {
24+
useQueryState: (key: string) => {
25+
const [mode, setMode] = useState(modeState.initial)
26+
if (key !== 'mode') return [null, mockSetSearchQuery]
27+
modeState.set = setMode
28+
return [mode, setMode]
29+
},
30+
useQueryStates: () => [{}, mockSetSearchFilters],
31+
}
32+
})
2133
vi.mock('posthog-js/react', () => ({ usePostHog: () => null }))
2234
vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent }))
2335

2436
import { ModeSwitcher } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher'
25-
import { useMothershipModeStore } from '@/stores/mothership-mode/store'
2637

2738
let root: Root | null = null
2839
let container: HTMLDivElement | null = null
@@ -52,9 +63,17 @@ function items(): HTMLElement[] {
5263
return Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
5364
}
5465

66+
function select(index: number) {
67+
act(() => {
68+
items()[index].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
69+
})
70+
}
71+
5572
beforeEach(() => {
5673
mockCaptureEvent.mockClear()
57-
useMothershipModeStore.getState().reset()
74+
mockSetSearchQuery.mockClear()
75+
mockSetSearchFilters.mockClear()
76+
modeState.initial = 'build'
5877
})
5978

6079
afterEach(() => {
@@ -89,15 +108,11 @@ describe('ModeSwitcher', () => {
89108
expect(rows[2].querySelector('svg')).toBeNull()
90109
})
91110

92-
it('switches the shared mode and reports the change', () => {
111+
it('writes the chosen mode to the URL and reports the change', () => {
93112
mount()
94113
openMenu()
114+
select(1)
95115

96-
act(() => {
97-
items()[1].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
98-
})
99-
100-
expect(useMothershipModeStore.getState().mode).toBe('search')
101116
expect(trigger().textContent).toBe('Search')
102117
expect(mockCaptureEvent).toHaveBeenCalledWith(null, 'chat_mode_changed', {
103118
workspace_id: 'workspace-1',
@@ -106,16 +121,21 @@ describe('ModeSwitcher', () => {
106121
expect(mockSetSearchQuery).not.toHaveBeenCalled()
107122
})
108123

124+
it('reads the mode from the URL on mount', () => {
125+
modeState.initial = 'assistant'
126+
mount()
127+
128+
expect(trigger().textContent).toBe('Assistant')
129+
expect(trigger().getAttribute('aria-label')).toBe('Mode: Assistant')
130+
})
131+
109132
it('drops the search query from the URL when leaving Search', () => {
110-
useMothershipModeStore.getState().setMode('search')
133+
modeState.initial = 'search'
111134
mount()
112135
openMenu()
136+
select(0)
113137

114-
act(() => {
115-
items()[0].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
116-
})
117-
118-
expect(useMothershipModeStore.getState().mode).toBe('build')
138+
expect(trigger().textContent).toBe('Build')
119139
expect(mockSetSearchQuery).toHaveBeenCalledWith(null, { history: 'replace', scroll: false })
120140
expect(mockSetSearchFilters).toHaveBeenCalledWith(
121141
{ source: null, updated: null },
@@ -126,12 +146,9 @@ describe('ModeSwitcher', () => {
126146
it('does not report re-selecting the active mode', () => {
127147
mount()
128148
openMenu()
149+
select(0)
129150

130-
act(() => {
131-
items()[0].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
132-
})
133-
134-
expect(useMothershipModeStore.getState().mode).toBe('build')
151+
expect(trigger().textContent).toBe('Build')
135152
expect(mockCaptureEvent).not.toHaveBeenCalled()
136153
})
137154
})

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,15 @@ import { useParams } from 'next/navigation'
1414
import { useQueryState, useQueryStates } from 'nuqs'
1515
import { usePostHog } from 'posthog-js/react'
1616
import { captureEvent } from '@/lib/posthog/client'
17+
import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode'
1718
import {
1819
CLEARED_SEARCH_FILTERS,
20+
MOTHERSHIP_MODES,
21+
type MothershipMode,
1922
resourceUrlKeys,
2023
searchFilterParsers,
2124
searchQueryParam,
2225
} from '@/app/workspace/[workspaceId]/home/search-params'
23-
import {
24-
MOTHERSHIP_MODES,
25-
type MothershipMode,
26-
useMothershipModeStore,
27-
} from '@/stores/mothership-mode/store'
2826

2927
const MODE_LABELS: Record<MothershipMode, string> = {
3028
build: 'Build',
@@ -42,16 +40,15 @@ const MODE_LABELS: Record<MothershipMode, string> = {
4240
export const ModeSwitcher = memo(function ModeSwitcher() {
4341
const { workspaceId } = useParams<{ workspaceId: string }>()
4442
const posthog = usePostHog()
45-
const mode = useMothershipModeStore((state) => state.mode)
46-
const setMode = useMothershipModeStore((state) => state.setMode)
43+
const [mode, setMode] = useMothershipMode()
4744

4845
const [, setSearchQueryParam] = useQueryState(searchQueryParam.key, searchQueryParam.parser)
4946
const [, setSearchFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys)
5047

5148
/** Leaving Search drops the query from the URL, so a clean URL always means no search is showing. */
5249
const handleSelect = (next: MothershipMode) => {
5350
if (next === mode) return
54-
setMode(next)
51+
void setMode(next)
5552
if (next !== 'search') {
5653
void setSearchQueryParam(null, { history: 'replace', scroll: false })
5754
void setSearchFilters(CLEARED_SEARCH_FILTERS, { history: 'replace', scroll: false })

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,15 @@ import { persistImportedWorkflow } from '@/lib/workflows/operations/import-expor
4949
import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results'
5050
import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
5151
import { SuggestedActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions'
52+
import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode'
5253
import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
5354
import {
5455
resolveResourceEventPresentation,
5556
resolveResourceSelectionUpdate,
5657
} from '@/app/workspace/[workspaceId]/home/resource-view-policy'
5758
import {
5859
CLEARED_SEARCH_FILTERS,
60+
type MothershipMode,
5961
resourceParam,
6062
resourceUrlKeys,
6163
searchFilterParsers,
@@ -67,7 +69,6 @@ import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
6769
import { useWorkflows } from '@/hooks/queries/workflows'
6870
import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
6971
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
70-
import { useMothershipModeStore } from '@/stores/mothership-mode/store'
7172
import type { ChatContext } from '@/stores/panel'
7273
import {
7374
ChatSurfaceProvider,
@@ -185,16 +186,15 @@ export function Home({ chatId, userName, userId }: HomeProps) {
185186
},
186187
[setSearchQueryParam, setSearchFilters]
187188
)
189+
const [composerMode, setComposerMode] = useMothershipMode()
188190
/**
189-
* A URL that carries a query opens in Search mode with the query in the box,
190-
* whether it arrived by link or by navigating back to it; the composer
191-
* follows the live query the same way (below), so the box and the results
192-
* never show two different queries.
191+
* A link that carries a query but no mode opens in Search with the query in
192+
* the box; the composer follows the live query the same way (below), so the
193+
* box and the results never show two different queries.
193194
*/
194195
useEffect(() => {
195-
if (searchQuery) useMothershipModeStore.getState().setMode('search')
196-
}, [searchQuery])
197-
const composerMode = useMothershipModeStore((state) => state.mode)
196+
if (searchQuery && composerMode === 'build') void setComposerMode('search')
197+
}, [searchQuery, composerMode, setComposerMode])
198198
/** The bases an Ask turn is grounded in; read through a ref so a list refresh never rebuilds the submit handler. */
199199
const { data: knowledgeBases = EMPTY_KNOWLEDGE_BASES } = useKnowledgeBasesQuery(workspaceId)
200200
const knowledgeBasesRef = useRef(knowledgeBases)
@@ -475,7 +475,12 @@ export function Home({ chatId, userName, userId }: HomeProps) {
475475
}, [workspaceId, getCurrentRequestId, stopGeneration])
476476

477477
const handleSubmit = useCallback(
478-
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
478+
(
479+
text: string,
480+
fileAttachments?: FileAttachmentForApi[],
481+
contexts?: ChatContext[],
482+
modeOverride?: MothershipMode
483+
) => {
479484
const trimmed = text.trim()
480485
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
481486

@@ -491,7 +496,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
491496
* be searched: attachments alone have nothing to search for. Assistant
492497
* makes the query a turn of the agent grounded in the sources.
493498
*/
494-
const mode = useMothershipModeStore.getState().mode
499+
const mode = modeOverride ?? composerMode
495500
const answering = mode === 'assistant'
496501
if (mode === 'search') {
497502
if (trimmed) setSearchQuery(trimmed)
@@ -516,27 +521,37 @@ export function Home({ chatId, userName, userId }: HomeProps) {
516521
answering ? { requestMode: 'ask' } : undefined
517522
)
518523
},
519-
[workspaceId, chatId, prepareResourceViewForAgentTurn, sendMessage, setSearchQuery]
524+
[
525+
workspaceId,
526+
chatId,
527+
composerMode,
528+
prepareResourceViewForAgentTurn,
529+
sendMessage,
530+
setSearchQuery,
531+
]
520532
)
521533

522534
/** An emptied search box returns to the sources; nothing else reads the cleared query. */
523535
const clearSearch = useCallback(() => setSearchQuery(''), [setSearchQuery])
524536

525-
/** Summarize or Answer on a result: switch to Assistant and hand the question to it. */
537+
/**
538+
* Summarize or Answer on a result: switch to Assistant and hand the question
539+
* to it. The submit reads the mode from this render, so it is sent as an
540+
* Assistant turn directly rather than waiting for the URL to update.
541+
*/
526542
const handleSummarize = (prompt: string) => {
527-
useMothershipModeStore.getState().setMode('assistant')
543+
void setComposerMode('assistant')
528544
setSearchQuery('')
529-
handleSubmit(prompt)
545+
handleSubmit(prompt, undefined, undefined, 'assistant')
530546
}
531547
/**
532548
* A chat that already exists never opens in Search: its transcript is a
533549
* conversation, and search results never join it. Build and Assistant both
534550
* carry over, so a follow-up stays grounded in the sources.
535551
*/
536552
useEffect(() => {
537-
const store = useMothershipModeStore.getState()
538-
if (chatId && store.mode === 'search') store.setMode('build')
539-
}, [chatId])
553+
if (chatId && composerMode === 'search') void setComposerMode('build')
554+
}, [chatId, composerMode, setComposerMode])
540555
const showSearchResults = composerMode === 'search' && searchQuery.trim().length > 0
541556
const searchResults = showSearchResults ? (
542557
<KnowledgeSearchResults
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* The URL a new chat is handed off to once the server names it. The current
3+
* query string rides along so the composer's URL-backed state, the mode above
4+
* all, survives the path swap: the first Assistant message must not bounce the
5+
* person back to Build.
6+
*/
7+
export function chatUrl(workspaceId: string, chatId: string): string {
8+
return `/workspace/${workspaceId}/chat/${chatId}${window.location.search}`
9+
}

apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export {
55
shouldActivateResourceEvent,
66
useChat,
77
} from './use-chat'
8+
export { useMothershipMode } from './use-mothership-mode'
89
export { useMothershipResize } from './use-mothership-resize'

0 commit comments

Comments
 (0)