Skip to content

Commit b520971

Browse files
committed
feat(home): add Ask mode, an agent turn grounded in the searched sources
1 parent bf3ac25 commit b520971

10 files changed

Lines changed: 151 additions & 28 deletions

File tree

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

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useQueryStates } from 'nuqs'
88
import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowledge'
99
import { matchSnippet } from '@/lib/knowledge/search/snippet'
1010
import { connectorDisplayName } from '@/lib/sim-search/connectors'
11+
import { searchedKnowledgeBases } from '@/lib/sim-search/knowledge-bases'
1112
import {
1213
highlightTerms,
1314
SOURCE_ROW_CLASSES,
@@ -33,8 +34,6 @@ import { useKnowledgeBasesQuery, useWorkspaceKnowledgeSearch } from '@/hooks/que
3334

3435
const EMPTY_MEMBER_CONNECTORS: WorkspaceMemberConnector[] = []
3536

36-
/** A search spans at most this many knowledge bases. */
37-
const MAX_SEARCHED_KNOWLEDGE_BASES = 20
3837
/** Filters appear only once a list is long and mixed enough for them to help. */
3938
const FILTERS_MIN_RESULTS = 10
4039
const DAY_MS = 24 * 60 * 60 * 1000
@@ -178,14 +177,7 @@ export function KnowledgeSearchResults({
178177
isPending: basesPending,
179178
error: basesError,
180179
} = useKnowledgeBasesQuery(workspaceId)
181-
/**
182-
* The list also carries the viewer's legacy personal bases, which have no
183-
* workspace; a search names one workspace and refuses a base outside it.
184-
*/
185-
const knowledgeBaseIds = knowledgeBases
186-
.filter((kb) => kb.workspaceId === workspaceId)
187-
.slice(0, MAX_SEARCHED_KNOWLEDGE_BASES)
188-
.map((kb) => kb.id)
180+
const knowledgeBaseIds = searchedKnowledgeBases(knowledgeBases, workspaceId).map((kb) => kb.id)
189181
const {
190182
data: results,
191183
isPending,

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,14 @@ describe('SuggestedActions', () => {
128128
expect(document.querySelector('[data-testid="search-sources"]')).not.toBeNull()
129129
expect(rows()).toHaveLength(0)
130130
})
131+
132+
it('shows the sources in Ask mode, which answers from them', () => {
133+
mount()
134+
135+
act(() => useMothershipModeStore.getState().setMode('ask'))
136+
137+
expect(heading()).toBe('Sources')
138+
expect(document.querySelector('[data-testid="search-sources"]')).not.toBeNull()
139+
expect(rows()).toHaveLength(0)
140+
})
131141
})

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ const INITIAL_ACTIONS: Action[] = [
234234
/** Section heading per composer mode — Search reads as a connect-your-sources list. */
235235
const HEADINGS: Record<MothershipMode, string> = {
236236
build: 'Suggested actions',
237+
ask: 'Sources',
237238
search: 'Sources',
238239
}
239240

@@ -372,7 +373,7 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
372373
`collapsible-up`/`-down` interpolate height alone, so a margin here
373374
would hold its full value through the close and then vanish on unmount,
374375
snapping the content below up. */}
375-
{mode === 'search' && workspaceId ? (
376+
{mode !== 'build' && workspaceId ? (
376377
<div className='pt-1.5'>
377378
<SearchSources workspaceId={workspaceId} />
378379
</div>

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,22 +78,23 @@ describe('ModeSwitcher', () => {
7878
expect(button.querySelector('svg')).toBeNull()
7979
})
8080

81-
it('lists both modes and checks the active one', () => {
81+
it('lists every mode and checks the active one', () => {
8282
mount()
8383
openMenu()
8484

8585
const rows = items()
86-
expect(rows.map((row) => row.textContent)).toEqual(['Build', 'Search'])
86+
expect(rows.map((row) => row.textContent)).toEqual(['Build', 'Ask', 'Search'])
8787
expect(rows[0].querySelector('svg')).not.toBeNull()
8888
expect(rows[1].querySelector('svg')).toBeNull()
89+
expect(rows[2].querySelector('svg')).toBeNull()
8990
})
9091

9192
it('switches the shared mode and reports the change', () => {
9293
mount()
9394
openMenu()
9495

9596
act(() => {
96-
items()[1].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
97+
items()[2].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
9798
})
9899

99100
expect(useMothershipModeStore.getState().mode).toBe('search')

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,15 @@ import {
2828

2929
const MODE_LABELS: Record<MothershipMode, string> = {
3030
build: 'Build',
31+
ask: 'Ask',
3132
search: 'Search',
3233
}
3334

3435
/**
35-
* The composer's Build / Search switcher: a label-only `Chip` in its `round`
36+
* The composer's Build / Ask / Search switcher: a label-only `Chip` in its `round`
3637
* shape — chip chrome throughout (`--text-body` label, `--surface-hover` on
3738
* hover, no text-color shift), fully round to sit in the toolbar's row of
38-
* round controls — opening a two-row menu that checks the active mode, as
39+
* round controls — opening a menu that checks the active mode, as
3940
* `ChipDropdown` does.
4041
*/
4142
export const ModeSwitcher = memo(function ModeSwitcher() {

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

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useQueryState, useQueryStates } from 'nuqs'
2121
import { usePostHog } from 'posthog-js/react'
2222
import { requestJson } from '@/lib/api/client/request'
2323
import { createWorkflowContract } from '@/lib/api/contracts'
24+
import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge/base'
2425
import {
2526
LandingPromptStorage,
2627
type LandingWorkflowSeed,
@@ -33,6 +34,10 @@ import {
3334
type MothershipSendMessageDetail,
3435
} from '@/lib/mothership/events'
3536
import { captureEvent } from '@/lib/posthog/client'
37+
import {
38+
searchedKnowledgeBases,
39+
withSearchedKnowledgeContexts,
40+
} from '@/lib/sim-search/knowledge-bases'
3641
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
3742
/**
3843
* Imported from its own folder, not the components barrel: the workflow copilot
@@ -57,6 +62,7 @@ import {
5762
searchQueryParam,
5863
} from '@/app/workspace/[workspaceId]/home/search-params'
5964
import { useFolders } from '@/hooks/queries/folders'
65+
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
6066
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
6167
import { useWorkflows } from '@/hooks/queries/workflows'
6268
import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
@@ -87,6 +93,9 @@ import type {
8793

8894
const logger = createLogger('Home')
8995

96+
/** Stable empty list, so a missing base list never rebuilds what reads it. */
97+
const EMPTY_KNOWLEDGE_BASES: KnowledgeBaseData[] = []
98+
9099
/**
91100
* The resource preview panel pulls in the file-viewer stack (rich-markdown
92101
* editor, CSV/PDF viewers). It only renders once a chat has messages, so it is
@@ -182,6 +191,12 @@ export function Home({ chatId, userName, userId }: HomeProps) {
182191
if (initialSearchQuery) useMothershipModeStore.getState().setMode('search')
183192
}, [initialSearchQuery])
184193
const composerMode = useMothershipModeStore((state) => state.mode)
194+
/** The bases an Ask turn is grounded in; read through a ref so a list refresh never rebuilds the submit handler. */
195+
const { data: knowledgeBases = EMPTY_KNOWLEDGE_BASES } = useKnowledgeBasesQuery(workspaceId)
196+
const knowledgeBasesRef = useRef(knowledgeBases)
197+
useEffect(() => {
198+
knowledgeBasesRef.current = knowledgeBases
199+
}, [knowledgeBases])
185200
const hasCheckedLandingStorageRef = useRef(false)
186201
const initialViewInputRef = useRef<HTMLDivElement>(null)
187202
const initialViewUserInputRef = useRef<UserInputHandle>(null)
@@ -473,7 +488,8 @@ export function Home({ chatId, userName, userId }: HomeProps) {
473488
* Search mode answers with documents, not a turn of the agent, and only
474489
* a query can be answered: attachments alone have nothing to search for.
475490
*/
476-
if (useMothershipModeStore.getState().mode === 'search') {
491+
const mode = useMothershipModeStore.getState().mode
492+
if (mode === 'search') {
477493
if (trimmed) setSearchQuery(trimmed)
478494
return
479495
}
@@ -483,30 +499,39 @@ export function Home({ chatId, userName, userId }: HomeProps) {
483499
}
484500

485501
prepareResourceViewForAgentTurn()
486-
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
502+
/** Ask is a turn of the agent grounded in the searched sources. */
503+
const turnContexts =
504+
mode === 'ask'
505+
? withSearchedKnowledgeContexts(
506+
contexts,
507+
searchedKnowledgeBases(knowledgeBasesRef.current, workspaceId)
508+
)
509+
: contexts
510+
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, turnContexts)
487511
},
488512
[workspaceId, chatId, prepareResourceViewForAgentTurn, sendMessage, setSearchQuery]
489513
)
490514

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

494-
/** Summarize on a result: hand the document to the agent in Build mode. */
518+
/** Summarize or Answer on a result: hand the question to the agent in Ask mode. */
495519
const handleSummarize = useCallback(
496520
(prompt: string) => {
497-
useMothershipModeStore.getState().setMode('build')
521+
useMothershipModeStore.getState().setMode('ask')
498522
setSearchQuery('')
499523
handleSubmit(prompt)
500524
},
501525
[handleSubmit, setSearchQuery]
502526
)
503527
/**
504-
* A chat that already exists opens in Build: its transcript is a
505-
* conversation, and search results never join it. A new chat keeps whatever
506-
* mode the person used last.
528+
* A chat that already exists never opens in Search: its transcript is a
529+
* conversation, and search results never join it. Build and Ask both carry
530+
* over, so a follow-up question stays grounded in the sources.
507531
*/
508532
useEffect(() => {
509-
if (chatId) useMothershipModeStore.getState().setMode('build')
533+
const store = useMothershipModeStore.getState()
534+
if (chatId && store.mode === 'search') store.setMode('build')
510535
}, [chatId])
511536
const showSearchResults = composerMode === 'search' && searchQuery.trim().length > 0
512537
const searchResults = showSearchResults ? (

apps/sim/lib/posthog/events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,7 @@ export interface PostHogEventMap {
617617
/** The chat composer's mode switcher picked a different mode. */
618618
chat_mode_changed: {
619619
workspace_id: string
620-
mode: 'build' | 'search'
620+
mode: 'build' | 'ask' | 'search'
621621
}
622622

623623
/**
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
MAX_SEARCHED_KNOWLEDGE_BASES,
4+
searchedKnowledgeBases,
5+
withSearchedKnowledgeContexts,
6+
} from '@/lib/sim-search/knowledge-bases'
7+
8+
const base = (id: string, workspaceId: string | null = 'ws-1') => ({
9+
id,
10+
name: `Base ${id}`,
11+
workspaceId,
12+
})
13+
14+
describe('searchedKnowledgeBases', () => {
15+
it('keeps only the bases of the named workspace, capped', () => {
16+
const bases = [
17+
base('legacy', null),
18+
base('other', 'ws-2'),
19+
...Array.from({ length: MAX_SEARCHED_KNOWLEDGE_BASES + 1 }, (_, i) => base(`kb-${i}`)),
20+
]
21+
const searched = searchedKnowledgeBases(bases, 'ws-1')
22+
expect(searched).toHaveLength(MAX_SEARCHED_KNOWLEDGE_BASES)
23+
expect(searched.every((kb) => kb.workspaceId === 'ws-1')).toBe(true)
24+
})
25+
})
26+
27+
describe('withSearchedKnowledgeContexts', () => {
28+
it('attaches every searched base after the contexts the person chose', () => {
29+
expect(
30+
withSearchedKnowledgeContexts(
31+
[{ kind: 'file', fileId: 'f-1', label: 'notes.md' }],
32+
[base('kb-1')]
33+
)
34+
).toEqual([
35+
{ kind: 'file', fileId: 'f-1', label: 'notes.md' },
36+
{ kind: 'knowledge', knowledgeId: 'kb-1', label: 'Base kb-1' },
37+
])
38+
})
39+
40+
it('does not attach a base the person already mentioned', () => {
41+
const mentioned = { kind: 'knowledge' as const, knowledgeId: 'kb-1', label: 'Mine' }
42+
expect(withSearchedKnowledgeContexts([mentioned], [base('kb-1'), base('kb-2')])).toEqual([
43+
mentioned,
44+
{ kind: 'knowledge', knowledgeId: 'kb-2', label: 'Base kb-2' },
45+
])
46+
})
47+
48+
it('returns the bases alone when nothing was chosen', () => {
49+
expect(withSearchedKnowledgeContexts(undefined, [base('kb-1')])).toEqual([
50+
{ kind: 'knowledge', knowledgeId: 'kb-1', label: 'Base kb-1' },
51+
])
52+
})
53+
})
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge/base'
2+
import type { ChatContext } from '@/stores/panel/types'
3+
4+
/** A search, or an answer drawn from one, spans at most this many knowledge bases. */
5+
export const MAX_SEARCHED_KNOWLEDGE_BASES = 20
6+
7+
type SearchedKnowledgeBase = Pick<KnowledgeBaseData, 'id' | 'name' | 'workspaceId'>
8+
9+
/**
10+
* The bases a workspace search covers. The list also carries the viewer's
11+
* legacy personal bases, which have no workspace; a search names one workspace
12+
* and refuses a base outside it.
13+
*/
14+
export function searchedKnowledgeBases<T extends SearchedKnowledgeBase>(
15+
bases: readonly T[],
16+
workspaceId: string
17+
): T[] {
18+
return bases.filter((kb) => kb.workspaceId === workspaceId).slice(0, MAX_SEARCHED_KNOWLEDGE_BASES)
19+
}
20+
21+
/**
22+
* The contexts an Ask turn carries: every searched base, attached the way an
23+
* `@` mention attaches one, so the agent answers from the same documents the
24+
* Search panel shows. A base the person already mentioned is not attached twice.
25+
*/
26+
export function withSearchedKnowledgeContexts(
27+
contexts: readonly ChatContext[] | undefined,
28+
bases: readonly SearchedKnowledgeBase[]
29+
): ChatContext[] {
30+
const mentioned = new Set<string>()
31+
for (const context of contexts ?? []) {
32+
if (context.kind === 'knowledge' && context.knowledgeId) mentioned.add(context.knowledgeId)
33+
}
34+
const attached: ChatContext[] = bases
35+
.filter((kb) => !mentioned.has(kb.id))
36+
.map((kb) => ({ kind: 'knowledge', knowledgeId: kb.id, label: kb.name }))
37+
return [...(contexts ?? []), ...attached]
38+
}

apps/sim/stores/mothership-mode/store.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { create } from 'zustand'
22
import { devtools } from 'zustand/middleware'
33

4-
export const MOTHERSHIP_MODES = ['build', 'search'] as const
4+
export const MOTHERSHIP_MODES = ['build', 'ask', 'search'] as const
55

66
export type MothershipMode = (typeof MOTHERSHIP_MODES)[number]
77

@@ -14,8 +14,10 @@ interface MothershipModeState {
1414
const initialState: Pick<MothershipModeState, 'mode'> = { mode: 'build' }
1515

1616
/**
17-
* The chat composer's mode — Build (default) or Search — read by the input's
18-
* mode switcher and by the suggested actions beneath the input.
17+
* The chat composer's mode — Build (default), Ask, or Search — read by the
18+
* input's mode switcher and by the suggested actions beneath the input. Ask is
19+
* a turn of the agent grounded in the searched sources; Search answers with
20+
* documents and no turn at all.
1921
*
2022
* A store rather than `Home` state because `Home` remounts per chat
2123
* (`key={chatId}`) and the new-chat → `/chat/[chatId]` handoff must carry the

0 commit comments

Comments
 (0)