diff --git a/packages/web/src/app/[domain]/chat/[id]/components/chatThreadPanel.tsx b/packages/web/src/app/[domain]/chat/[id]/components/chatThreadPanel.tsx index 3653c049..92f3ab6e 100644 --- a/packages/web/src/app/[domain]/chat/[id]/components/chatThreadPanel.tsx +++ b/packages/web/src/app/[domain]/chat/[id]/components/chatThreadPanel.tsx @@ -2,13 +2,12 @@ import { ResizablePanel } from '@/components/ui/resizable'; import { ChatThread } from '@/features/chat/components/chatThread'; -import { LanguageModelInfo, SBChatMessage, SET_CHAT_STATE_QUERY_PARAM, SetChatStatePayload } from '@/features/chat/types'; +import { LanguageModelInfo, SBChatMessage, SearchScope, SET_CHAT_STATE_QUERY_PARAM, SetChatStatePayload } from '@/features/chat/types'; import { RepositoryQuery, SearchContextQuery } from '@/lib/types'; import { CreateUIMessage } from 'ai'; import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import { useChatId } from '../../useChatId'; -import { SearchScopeItem } from '@/features/chat/components/chatBox/searchScopeSelector'; interface ChatThreadPanelProps { languageModels: LanguageModelInfo[]; @@ -33,33 +32,12 @@ export const ChatThreadPanel = ({ const router = useRouter(); const searchParams = useSearchParams(); const [inputMessage, setInputMessage] = useState | undefined>(undefined); - + // Use the last user's last message to determine what repos and contexts we should select by default. const lastUserMessage = messages.findLast((message) => message.role === "user"); - const defaultSelectedRepos = lastUserMessage?.metadata?.selectedRepos ?? []; - const defaultSelectedReposets = lastUserMessage?.metadata?.selectedReposets ?? []; + const defaultSelectedSearchScopes = lastUserMessage?.metadata?.selectedSearchScopes ?? []; + const [selectedSearchScopes, setSelectedSearchScopes] = useState(defaultSelectedSearchScopes); - const [selectedItems, setSelectedItems] = useState([ - ...defaultSelectedRepos.map(repoName => { - const repoInfo = repos.find(r => r.repoName === repoName); - return { - type: 'repo' as const, - value: repoName, - name: repoInfo?.repoDisplayName || repoName.split('/').pop() || repoName, - codeHostType: repoInfo?.codeHostType || '' - }; - }), - ...defaultSelectedReposets.map(reposetName => { - const reposet = searchContexts.find(c => c.name === reposetName); - return { - type: 'reposet' as const, - value: reposetName, - name: reposetName, - repoCount: reposet?.repoNames.length || 0 - }; - }) - ]); - useEffect(() => { const setChatState = searchParams.get(SET_CHAT_STATE_QUERY_PARAM); if (!setChatState) { @@ -67,28 +45,9 @@ export const ChatThreadPanel = ({ } try { - const { inputMessage, selectedRepos, selectedReposets } = JSON.parse(setChatState) as SetChatStatePayload; + const { inputMessage, selectedSearchScopes } = JSON.parse(setChatState) as SetChatStatePayload; setInputMessage(inputMessage); - setSelectedItems([ - ...selectedRepos.map(repoName => { - const repoInfo = repos.find(r => r.repoName === repoName); - return { - type: 'repo' as const, - value: repoName, - name: repoInfo?.repoDisplayName || repoName.split('/').pop() || repoName, - codeHostType: repoInfo?.codeHostType || '' - }; - }), - ...selectedReposets.map(reposetName => { - const reposet = searchContexts.find(c => c.name === reposetName); - return { - type: 'reposet' as const, - value: reposetName, - name: reposetName, - repoCount: reposet?.repoNames.length || 0 - }; - }) - ]); + setSelectedSearchScopes(selectedSearchScopes); } catch { console.error('Invalid message in URL'); } @@ -97,7 +56,7 @@ export const ChatThreadPanel = ({ const newSearchParams = new URLSearchParams(searchParams.toString()); newSearchParams.delete(SET_CHAT_STATE_QUERY_PARAM); router.replace(`?${newSearchParams.toString()}`, { scroll: false }); - }, [searchParams, router, repos, searchContexts]); + }, [searchParams, router]); return ( diff --git a/packages/web/src/app/[domain]/chat/components/newChatPanel.tsx b/packages/web/src/app/[domain]/chat/components/newChatPanel.tsx index 190aff7c..91ae3f96 100644 --- a/packages/web/src/app/[domain]/chat/components/newChatPanel.tsx +++ b/packages/web/src/app/[domain]/chat/components/newChatPanel.tsx @@ -5,12 +5,11 @@ import { ChatBox } from "@/features/chat/components/chatBox"; import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar"; import { CustomSlateEditor } from "@/features/chat/customSlateEditor"; import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread"; -import { LanguageModelInfo } from "@/features/chat/types"; +import { LanguageModelInfo, SearchScope } from "@/features/chat/types"; import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; import { useCallback, useState } from "react"; import { Descendant } from "slate"; import { useLocalStorage } from "usehooks-ts"; -import { SearchScopeItem } from "@/features/chat/components/chatBox/searchScopeSelector"; interface NewChatPanelProps { languageModels: LanguageModelInfo[]; @@ -25,13 +24,13 @@ export const NewChatPanel = ({ searchContexts, order, }: NewChatPanelProps) => { - const [selectedItems, setSelectedItems] = useLocalStorage("selectedContextItems", [], { initializeWithValue: false }); + const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage("selectedSearchScopes", [], { initializeWithValue: false }); const { createNewChatThread, isLoading } = useCreateNewChatThread(); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const onSubmit = useCallback((children: Descendant[]) => { - createNewChatThread(children, selectedItems); - }, [createNewChatThread, selectedItems]); + createNewChatThread(children, selectedSearchScopes); + }, [createNewChatThread, selectedSearchScopes]); return ( @@ -50,7 +49,7 @@ export const NewChatPanel = ({ preferredSuggestionsBoxPlacement="bottom-start" isRedirecting={isLoading} languageModels={languageModels} - selectedItems={selectedItems} + selectedSearchScopes={selectedSearchScopes} searchContexts={searchContexts} onContextSelectorOpenChanged={setIsContextSelectorOpen} /> @@ -59,8 +58,8 @@ export const NewChatPanel = ({ languageModels={languageModels} repos={repos} searchContexts={searchContexts} - selectedItems={selectedItems} - onSelectedItemsChange={setSelectedItems} + selectedSearchScopes={selectedSearchScopes} + onSelectedSearchScopesChange={setSelectedSearchScopes} isContextSelectorOpen={isContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen} /> diff --git a/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx b/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx index f3ccddfc..259c738b 100644 --- a/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx +++ b/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx @@ -3,13 +3,12 @@ import { Separator } from "@/components/ui/separator"; import { ChatBox } from "@/features/chat/components/chatBox"; import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar"; -import { LanguageModelInfo } from "@/features/chat/types"; +import { LanguageModelInfo, SearchScope } from "@/features/chat/types"; import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread"; import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; import { useState } from "react"; import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar"; import { useLocalStorage } from "usehooks-ts"; -import { SearchScopeItem } from "@/features/chat/components/chatBox/searchScopeSelector"; import { DemoExamples } from "@/types"; import { AskSourcebotDemoCards } from "./askSourcebotDemoCards"; @@ -34,7 +33,7 @@ export const AgenticSearch = ({ demoExamples, }: AgenticSearchProps) => { const { createNewChatThread, isLoading } = useCreateNewChatThread(); - const [selectedItems, setSelectedItems] = useLocalStorage("selectedContextItems", [], { initializeWithValue: false }); + const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage("selectedSearchScopes", [], { initializeWithValue: false }); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); return ( @@ -42,12 +41,12 @@ export const AgenticSearch = ({
{ - createNewChatThread(children, selectedItems); + createNewChatThread(children, selectedSearchScopes); }} className="min-h-[50px]" isRedirecting={isLoading} languageModels={languageModels} - selectedItems={selectedItems} + selectedSearchScopes={selectedSearchScopes} searchContexts={searchContexts} onContextSelectorOpenChanged={setIsContextSelectorOpen} /> @@ -58,8 +57,8 @@ export const AgenticSearch = ({ languageModels={languageModels} repos={repos} searchContexts={searchContexts} - selectedItems={selectedItems} - onSelectedItemsChange={setSelectedItems} + selectedSearchScopes={selectedSearchScopes} + onSelectedSearchScopesChange={setSelectedSearchScopes} isContextSelectorOpen={isContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen} /> diff --git a/packages/web/src/app/api/(server)/chat/route.ts b/packages/web/src/app/api/(server)/chat/route.ts index ac040c06..54325599 100644 --- a/packages/web/src/app/api/(server)/chat/route.ts +++ b/packages/web/src/app/api/(server)/chat/route.ts @@ -2,7 +2,7 @@ import { sew, withAuth, withOrgMembership } from "@/actions"; import { env } from "@/env.mjs"; import { _getConfiguredLanguageModelsFull, updateChatMessages, updateChatName } from "@/features/chat/actions"; import { createAgentStream } from "@/features/chat/agent"; -import { additionalChatRequestParamsSchema, SBChatMessage } from "@/features/chat/types"; +import { additionalChatRequestParamsSchema, SBChatMessage, SearchScope } from "@/features/chat/types"; import { getAnswerPartFromAssistantMessage } from "@/features/chat/utils"; import { ErrorCode } from "@/lib/errorCodes"; import { notFound, schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; @@ -64,12 +64,11 @@ export async function POST(req: Request) { return serviceErrorResponse(schemaValidationError(parsed.error)); } - const { messages, id, selectedRepos, selectedReposets, languageModelId } = parsed.data; + const { messages, id, selectedSearchScopes, languageModelId } = parsed.data; const response = await chatHandler({ messages, id, - selectedRepos, - selectedReposets, + selectedSearchScopes, languageModelId, }, domain); @@ -93,12 +92,11 @@ const mergeStreamAsync = async (stream: StreamTextResult, writer: UIMe interface ChatHandlerProps { messages: SBChatMessage[]; id: string; - selectedRepos: string[]; - selectedReposets?: string[]; + selectedSearchScopes: SearchScope[]; languageModelId: string; } -const chatHandler = ({ messages, id, selectedRepos, selectedReposets, languageModelId }: ChatHandlerProps, domain: string) => sew(async () => +const chatHandler = ({ messages, id, selectedSearchScopes, languageModelId }: ChatHandlerProps, domain: string) => sew(async () => withAuth((userId) => withOrgMembership(userId, domain, async ({ org }) => { const chat = await prisma.chat.findUnique({ @@ -188,26 +186,30 @@ const chatHandler = ({ messages, id, selectedRepos, selectedReposets, languageMo const startTime = new Date(); - // Expand search contexts to repos - let expandedRepos = [...selectedRepos]; - if (selectedReposets && selectedReposets.length > 0) { - const searchReposets = await prisma.searchContext.findMany({ - where: { - orgId: org.id, - name: { in: selectedReposets } - }, - include: { - repos: true + const expandedReposArrays = await Promise.all(selectedSearchScopes.map(async (scope) => { + if (scope.type === 'repo') { + return [scope.value]; + } + + if (scope.type === 'reposet') { + const reposet = await prisma.searchContext.findFirst({ + where: { + orgId: org.id, + name: scope.value + }, + include: { + repos: true + } + }); + + if (reposet) { + return reposet.repos.map(repo => repo.name); } - }); + } - const reposetRepos = searchReposets.flatMap(reposet => - reposet.repos.map(repo => repo.name) - ); - - // Combine and deduplicate repos - expandedRepos = Array.from(new Set([...selectedRepos, ...reposetRepos])); - } + return []; + })); + const expandedRepos = expandedReposArrays.flat(); const researchStream = await createAgentStream({ model, @@ -215,7 +217,7 @@ const chatHandler = ({ messages, id, selectedRepos, selectedReposets, languageMo headers, inputMessages: messageHistory, inputSources: sources, - selectedRepos: expandedRepos, + searchScopeRepoNames: expandedRepos, onWriteSource: (source) => { writer.write({ type: 'data-source', @@ -241,6 +243,7 @@ const chatHandler = ({ messages, id, selectedRepos, selectedReposets, languageMo totalOutputTokens: totalUsage.outputTokens, totalResponseTimeMs: new Date().getTime() - startTime.getTime(), modelName: languageModelConfig.displayName ?? languageModelConfig.model, + selectedSearchScopes, traceId, } }) diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 7f3ef93d..d443b99b 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -16,7 +16,7 @@ interface AgentOptions { model: LanguageModel; providerOptions?: ProviderOptions; headers?: Record; - selectedRepos: string[]; + searchScopeRepoNames: string[]; inputMessages: ModelMessage[]; inputSources: Source[]; onWriteSource: (source: Source) => void; @@ -35,12 +35,12 @@ export const createAgentStream = async ({ headers, inputMessages, inputSources, - selectedRepos, + searchScopeRepoNames, onWriteSource, traceId, }: AgentOptions) => { const baseSystemPrompt = createBaseSystemPrompt({ - selectedRepos, + searchScopeRepoNames, }); const stream = streamText({ @@ -50,7 +50,7 @@ export const createAgentStream = async ({ system: baseSystemPrompt, messages: inputMessages, tools: { - [toolNames.searchCode]: createCodeSearchTool(selectedRepos), + [toolNames.searchCode]: createCodeSearchTool(searchScopeRepoNames), [toolNames.readFiles]: readFilesTool, [toolNames.findSymbolReferences]: findSymbolReferencesTool, [toolNames.findSymbolDefinitions]: findSymbolDefinitionsTool, @@ -150,11 +150,11 @@ export const createAgentStream = async ({ } interface BaseSystemPromptOptions { - selectedRepos: string[]; + searchScopeRepoNames: string[]; } export const createBaseSystemPrompt = ({ - selectedRepos, + searchScopeRepoNames, }: BaseSystemPromptOptions) => { return ` You are a powerful agentic AI code assistant built into Sourcebot, the world's best code-intelligence platform. Your job is to help developers understand and navigate their large codebases. @@ -176,7 +176,7 @@ Your workflow has two distinct phases: The user has selected the following repositories for analysis: -${selectedRepos.map(repo => `- ${repo}`).join('\n')} +${searchScopeRepoNames.map(repo => `- ${repo}`).join('\n')} diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index 170aa62f..f5cfc974 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -3,7 +3,7 @@ import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { CustomEditor, LanguageModelInfo, MentionElement, RenderElementPropsFor } from "@/features/chat/types"; +import { CustomEditor, LanguageModelInfo, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types"; import { insertMention, slateContentToString } from "@/features/chat/utils"; import { cn, IS_MAC } from "@/lib/utils"; import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react"; @@ -18,7 +18,6 @@ import { Suggestion } from "./types"; import { useSuggestionModeAndQuery } from "./useSuggestionModeAndQuery"; import { useSuggestionsData } from "./useSuggestionsData"; import { useToast } from "@/components/hooks/use-toast"; -import { SearchScopeItem } from "./searchScopeSelector"; import { SearchContextQuery } from "@/lib/types"; interface ChatBoxProps { @@ -29,7 +28,7 @@ interface ChatBoxProps { isRedirecting?: boolean; isGenerating?: boolean; languageModels: LanguageModelInfo[]; - selectedItems: SearchScopeItem[]; + selectedSearchScopes: SearchScope[]; searchContexts: SearchContextQuery[]; onContextSelectorOpenChanged: (isOpen: boolean) => void; } @@ -42,7 +41,7 @@ export const ChatBox = ({ isRedirecting, isGenerating, languageModels, - selectedItems, + selectedSearchScopes, searchContexts, onContextSelectorOpenChanged, }: ChatBoxProps) => { @@ -53,7 +52,7 @@ export const ChatBox = ({ const { suggestions, isLoading } = useSuggestionsData({ suggestionMode, suggestionQuery, - selectedRepos: selectedItems.map((item) => { + selectedRepos: selectedSearchScopes.map((item) => { if (item.type === 'repo') { return [item.value]; } @@ -130,7 +129,7 @@ export const ChatBox = ({ } } - if (selectedItems.length === 0) { + if (selectedSearchScopes.length === 0) { return { isSubmitDisabled: true, isSubmitDisabledReason: "no-repos-selected", @@ -154,7 +153,7 @@ export const ChatBox = ({ editor.children, isRedirecting, isGenerating, - selectedItems.length, + selectedSearchScopes.length, selectedLanguageModel, ]) diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx b/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx index beb034e6..eba67729 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx @@ -3,14 +3,14 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { LanguageModelInfo } from "@/features/chat/types"; +import { LanguageModelInfo, SearchScope } from "@/features/chat/types"; import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; import { AtSignIcon } from "lucide-react"; import { useCallback } from "react"; import { ReactEditor, useSlate } from "slate-react"; import { useSelectedLanguageModel } from "../../useSelectedLanguageModel"; import { LanguageModelSelector } from "./languageModelSelector"; -import { SearchScopeSelector, type SearchScopeItem } from "./searchScopeSelector"; +import { SearchScopeSelector } from "./searchScopeSelector"; import { SearchScopeInfoCard } from "@/components/searchScopeInfoCard"; import { AtMentionInfoCard } from "@/components/atMentionInfoCard"; @@ -18,8 +18,8 @@ export interface ChatBoxToolbarProps { languageModels: LanguageModelInfo[]; repos: RepositoryQuery[]; searchContexts: SearchContextQuery[]; - selectedItems: SearchScopeItem[]; - onSelectedItemsChange: (items: SearchScopeItem[]) => void; + selectedSearchScopes: SearchScope[]; + onSelectedSearchScopesChange: (items: SearchScope[]) => void; isContextSelectorOpen: boolean; onContextSelectorOpenChanged: (isOpen: boolean) => void; } @@ -28,8 +28,8 @@ export const ChatBoxToolbar = ({ languageModels, repos, searchContexts, - selectedItems, - onSelectedItemsChange, + selectedSearchScopes, + onSelectedSearchScopesChange, isContextSelectorOpen, onContextSelectorOpenChanged, }: ChatBoxToolbarProps) => { @@ -68,8 +68,8 @@ export const ChatBoxToolbar = ({ className="bg-inherit w-fit h-6 min-h-6" repos={repos} searchContexts={searchContexts} - selectedItems={selectedItems} - onSelectedItemsChange={onSelectedItemsChange} + selectedSearchScopes={selectedSearchScopes} + onSelectedSearchScopesChange={onSelectedSearchScopesChange} isOpen={isContextSelectorOpen} onOpenChanged={onContextSelectorOpenChanged} /> diff --git a/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx b/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx index 77887a53..a1c8f595 100644 --- a/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx +++ b/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx @@ -4,13 +4,10 @@ import * as React from "react"; import { CheckIcon, ChevronDown, - FolderIcon, ScanSearchIcon, - LibraryBigIcon, } from "lucide-react"; -import Image from "next/image"; -import { cn, getCodeHostIcon } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -28,28 +25,14 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command"; - -export type RepoSearchScopeItem = { - type: 'repo'; - value: string; - name: string; - codeHostType: string; -} - -export type RepoSetSearchScopeItem = { - type: 'reposet'; - value: string; - name: string; - repoCount: number; -} - -export type SearchScopeItem = RepoSearchScopeItem | RepoSetSearchScopeItem; +import { RepoSetSearchScope, RepoSearchScope, SearchScope } from "../../types"; +import { SearchScopeIcon } from "../searchScopeIcon"; interface SearchScopeSelectorProps extends React.ButtonHTMLAttributes { repos: RepositoryQuery[]; searchContexts: SearchContextQuery[]; - selectedItems: SearchScopeItem[]; - onSelectedItemsChange: (items: SearchScopeItem[]) => void; + selectedSearchScopes: SearchScope[]; + onSelectedSearchScopesChange: (items: SearchScope[]) => void; className?: string; isOpen: boolean; onOpenChanged: (isOpen: boolean) => void; @@ -63,9 +46,9 @@ export const SearchScopeSelector = React.forwardRef< { repos, searchContexts, - onSelectedItemsChange, className, - selectedItems, + selectedSearchScopes, + onSelectedSearchScopesChange, isOpen, onOpenChanged, ...props @@ -81,43 +64,33 @@ export const SearchScopeSelector = React.forwardRef< if (event.key === "Enter") { onOpenChanged(true); } else if (event.key === "Backspace" && !event.currentTarget.value) { - const newSelectedItems = [...selectedItems]; + const newSelectedItems = [...selectedSearchScopes]; newSelectedItems.pop(); - onSelectedItemsChange(newSelectedItems); + onSelectedSearchScopesChange(newSelectedItems); } }; - const toggleItem = (item: SearchScopeItem) => { + const toggleItem = (item: SearchScope) => { // Store current scroll position before state update if (scrollContainerRef.current) { scrollPosition.current = scrollContainerRef.current.scrollTop; } - const isSelected = selectedItems.some( + const isSelected = selectedSearchScopes.some( (selected) => selected.type === item.type && selected.value === item.value ); - const isDemoMode = process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo"; - - let newSelectedItems: SearchScopeItem[]; - if (isSelected) { - newSelectedItems = selectedItems.filter( + const newSelectedItems = isSelected ? + selectedSearchScopes.filter( (selected) => !(selected.type === item.type && selected.value === item.value) - ); - } else { - // Limit selected search scope to 1 in demo mode - if (isDemoMode) { - newSelectedItems = [item]; - } else { - newSelectedItems = [...selectedItems, item]; - } - } + ) : + [...selectedSearchScopes, item]; - onSelectedItemsChange(newSelectedItems); + onSelectedSearchScopesChange(newSelectedItems); }; const handleClear = () => { - onSelectedItemsChange([]); + onSelectedSearchScopesChange([]); }; const handleTogglePopover = () => { @@ -125,14 +98,14 @@ export const SearchScopeSelector = React.forwardRef< }; const allSearchScopeItems = React.useMemo(() => { - const repoSetSearchScopeItems: RepoSetSearchScopeItem[] = searchContexts.map(context => ({ + const repoSetSearchScopeItems: RepoSetSearchScope[] = searchContexts.map(context => ({ type: 'reposet' as const, value: context.name, name: context.name, repoCount: context.repoNames.length })); - const repoSearchScopeItems: RepoSearchScopeItem[] = repos.map(repo => ({ + const repoSearchScopeItems: RepoSearchScope[] = repos.map(repo => ({ type: 'repo' as const, value: repo.repoName, name: repo.repoDisplayName || repo.repoName.split('/').pop() || repo.repoName, @@ -146,7 +119,7 @@ export const SearchScopeSelector = React.forwardRef< return allSearchScopeItems .map((item) => ({ item, - isSelected: selectedItems.some( + isSelected: selectedSearchScopes.some( (selected) => selected.type === item.type && selected.value === item.value ) })) @@ -159,7 +132,7 @@ export const SearchScopeSelector = React.forwardRef< if (a.item.type === 'repo' && b.item.type === 'reposet') return 1; return 0; }) - }, [allSearchScopeItems, selectedItems]); + }, [allSearchScopeItems, selectedSearchScopes]); // Restore scroll position after re-render React.useEffect(() => { @@ -189,9 +162,9 @@ export const SearchScopeSelector = React.forwardRef< className={cn("text-sm text-muted-foreground mx-1 font-medium")} > { - selectedItems.length === 0 ? `Search scopes` : - selectedItems.length === 1 ? selectedItems[0].name : - `${selectedItems.length} selected` + selectedSearchScopes.length === 0 ? `Search scopes` : + selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name : + `${selectedSearchScopes.length} selected` } @@ -229,25 +202,7 @@ export const SearchScopeSelector = React.forwardRef<
- {item.type === 'reposet' ? ( - - ) : ( - // Render code host icon for repos - (() => { - const codeHostIcon = item.codeHostType ? getCodeHostIcon(item.codeHostType) : null; - return codeHostIcon ? ( - {`${item.codeHostType} - ) : ( - - ); - })() - )} +
@@ -269,7 +224,7 @@ export const SearchScopeSelector = React.forwardRef< })} - {selectedItems.length > 0 && ( + {selectedSearchScopes.length > 0 && ( <> void; + selectedSearchScopes: SearchScope[]; + onSelectedSearchScopesChange: (items: SearchScope[]) => void; isChatReadonly: boolean; } @@ -48,8 +47,8 @@ export const ChatThread = ({ languageModels, repos, searchContexts, - selectedItems, - onSelectedItemsChange, + selectedSearchScopes, + onSelectedSearchScopesChange, isChatReadonly, }: ChatThreadProps) => { const domain = useDomain(); @@ -62,12 +61,6 @@ export const ChatThread = ({ const router = useRouter(); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); - const { selectedRepos, selectedReposets } = useMemo(() => { - const repos = selectedItems.filter(item => item.type === 'repo').map(item => item.value); - const reposets = selectedItems.filter(item => item.type === 'reposet').map(item => item.value); - return { selectedRepos: repos, selectedReposets: reposets }; - }, [selectedItems]); - // Initial state is from attachments that exist in in the chat history. const [sources, setSources] = useState( initialMessages?.flatMap((message) => @@ -122,12 +115,11 @@ export const ChatThread = ({ _sendMessage(message, { body: { - selectedRepos, - selectedReposets, + selectedSearchScopes, languageModelId: selectedLanguageModel.model, } satisfies AdditionalChatRequestParams, }); - }, [_sendMessage, selectedLanguageModel, toast, selectedRepos, selectedReposets]); + }, [_sendMessage, selectedLanguageModel, toast, selectedSearchScopes]); const messagePairs = useMessagePairs(messages); @@ -243,13 +235,13 @@ export const ChatThread = ({ const text = slateContentToString(children); const mentions = getAllMentionElements(children); - const message = createUIMessage(text, mentions.map(({ data }) => data), selectedRepos, selectedReposets); + const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes); sendMessage(message); setIsAutoScrollEnabled(true); resetEditor(editor); - }, [sendMessage, selectedRepos, selectedReposets]); + }, [sendMessage, selectedSearchScopes]); return ( <> @@ -327,7 +319,7 @@ export const ChatThread = ({ isGenerating={status === "streaming" || status === "submitted"} onStop={stop} languageModels={languageModels} - selectedItems={selectedItems} + selectedSearchScopes={selectedSearchScopes} searchContexts={searchContexts} onContextSelectorOpenChanged={setIsContextSelectorOpen} /> @@ -336,8 +328,8 @@ export const ChatThread = ({ languageModels={languageModels} repos={repos} searchContexts={searchContexts} - selectedItems={selectedItems} - onSelectedItemsChange={onSelectedItemsChange} + selectedSearchScopes={selectedSearchScopes} + onSelectedSearchScopesChange={onSelectedSearchScopesChange} isContextSelectorOpen={isContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen} /> diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index 7bfe49a0..ab3b676c 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -4,14 +4,16 @@ import { Card, CardContent } from '@/components/ui/card'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; -import { Brain, ChevronDown, ChevronRight, Clock, Cpu, InfoIcon, Loader2, Zap } from 'lucide-react'; +import { Brain, ChevronDown, ChevronRight, Clock, Cpu, InfoIcon, Loader2, ScanSearchIcon, Zap } from 'lucide-react'; import { MarkdownRenderer } from './markdownRenderer'; import { FindSymbolDefinitionsToolComponent } from './tools/findSymbolDefinitionsToolComponent'; import { FindSymbolReferencesToolComponent } from './tools/findSymbolReferencesToolComponent'; import { ReadFilesToolComponent } from './tools/readFilesToolComponent'; import { SearchCodeToolComponent } from './tools/searchCodeToolComponent'; import { SBChatMessageMetadata, SBChatMessagePart } from '../../types'; +import { SearchScopeIcon } from '../searchScopeIcon'; interface DetailsCardProps { @@ -61,6 +63,28 @@ export const DetailsCard = ({ {!isStreaming && ( <> + {metadata?.selectedSearchScopes && ( + + +
+ + {metadata.selectedSearchScopes.length} search scope{metadata.selectedSearchScopes.length === 1 ? '' : 's'} +
+
+ +
+
+ {metadata.selectedSearchScopes.map((item) => ( +
+ + {item.name} +
+ ))} +
+
+
+
+ )} {metadata?.modelName && (
diff --git a/packages/web/src/features/chat/components/searchScopeIcon.tsx b/packages/web/src/features/chat/components/searchScopeIcon.tsx new file mode 100644 index 00000000..933471f4 --- /dev/null +++ b/packages/web/src/features/chat/components/searchScopeIcon.tsx @@ -0,0 +1,32 @@ +import { cn, getCodeHostIcon } from "@/lib/utils"; +import { FolderIcon, LibraryBigIcon } from "lucide-react"; +import Image from "next/image"; +import { SearchScope } from "../types"; + +interface SearchScopeIconProps { + searchScope: SearchScope; + className?: string; +} + +export const SearchScopeIcon = ({ searchScope, className = "h-4 w-4" }: SearchScopeIconProps) => { + if (searchScope.type === 'reposet') { + return ; + } else { + // Render code host icon for repos + const codeHostIcon = searchScope.codeHostType ? getCodeHostIcon(searchScope.codeHostType) : null; + if (codeHostIcon) { + const size = className.includes('h-3') ? 12 : 16; + return ( + {`${searchScope.codeHostType} + ); + } else { + return ; + } + } +}; \ No newline at end of file diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts index 24d70907..18aa55a2 100644 --- a/packages/web/src/features/chat/types.ts +++ b/packages/web/src/features/chat/types.ts @@ -39,6 +39,28 @@ export const referenceSchema = z.discriminatedUnion('type', [ ]); export type Reference = z.infer; +export const repoSearchScopeSchema = z.object({ + type: z.literal('repo'), + value: z.string(), + name: z.string(), + codeHostType: z.string(), +}); +export type RepoSearchScope = z.infer; + +export const repoSetSearchScopeSchema = z.object({ + type: z.literal('reposet'), + value: z.string(), + name: z.string(), + repoCount: z.number(), +}); +export type RepoSetSearchScope = z.infer; + +export const searchScopeSchema = z.discriminatedUnion('type', [ + repoSearchScopeSchema, + repoSetSearchScopeSchema, +]); +export type SearchScope = z.infer; + export const sbChatMessageMetadataSchema = z.object({ modelName: z.string().optional(), totalInputTokens: z.number().optional(), @@ -50,8 +72,7 @@ export const sbChatMessageMetadataSchema = z.object({ timestamp: z.string(), // ISO date string userId: z.string(), })).optional(), - selectedRepos: z.array(z.string()).optional(), - selectedReposets: z.array(z.string()).optional(), + selectedSearchScopes: z.array(searchScopeSchema).optional(), traceId: z.string().optional(), }); @@ -139,8 +160,7 @@ export const SET_CHAT_STATE_QUERY_PARAM = 'setChatState'; export type SetChatStatePayload = { inputMessage: CreateUIMessage; - selectedRepos: string[]; - selectedReposets: string[]; + selectedSearchScopes: SearchScope[]; } @@ -157,7 +177,6 @@ export type LanguageModelInfo = { // Additional request body data that we send along to the chat API. export const additionalChatRequestParamsSchema = z.object({ languageModelId: z.string(), - selectedRepos: z.array(z.string()), - selectedReposets: z.array(z.string()), + selectedSearchScopes: z.array(searchScopeSchema), }); export type AdditionalChatRequestParams = z.infer; \ No newline at end of file diff --git a/packages/web/src/features/chat/useCreateNewChatThread.ts b/packages/web/src/features/chat/useCreateNewChatThread.ts index 3c108446..f0de7e1a 100644 --- a/packages/web/src/features/chat/useCreateNewChatThread.ts +++ b/packages/web/src/features/chat/useCreateNewChatThread.ts @@ -10,8 +10,7 @@ import { useRouter } from "next/navigation"; import { createChat } from "./actions"; import { isServiceError } from "@/lib/utils"; import { createPathWithQueryParams } from "@/lib/utils"; -import { SET_CHAT_STATE_QUERY_PARAM, SetChatStatePayload } from "./types"; -import { SearchScopeItem } from "./components/chatBox/searchScopeSelector"; +import { SearchScope, SET_CHAT_STATE_QUERY_PARAM, SetChatStatePayload } from "./types"; export const useCreateNewChatThread = () => { const domain = useDomain(); @@ -19,15 +18,11 @@ export const useCreateNewChatThread = () => { const { toast } = useToast(); const router = useRouter(); - const createNewChatThread = useCallback(async (children: Descendant[], selectedItems: SearchScopeItem[]) => { + const createNewChatThread = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[]) => { const text = slateContentToString(children); const mentions = getAllMentionElements(children); - // Extract repos and reposets from selectedItems - const selectedRepos = selectedItems.filter(item => item.type === 'repo').map(item => item.value); - const selectedReposets = selectedItems.filter(item => item.type === 'reposet').map(item => item.value); - - const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedRepos, selectedReposets); + const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedSearchScopes); setIsLoading(true); const response = await createChat(domain); @@ -42,8 +37,7 @@ export const useCreateNewChatThread = () => { const url = createPathWithQueryParams(`/${domain}/chat/${response.id}`, [SET_CHAT_STATE_QUERY_PARAM, JSON.stringify({ inputMessage, - selectedRepos, - selectedReposets, + selectedSearchScopes, } satisfies SetChatStatePayload)], ); diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index 36330ad3..d84835ef 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -12,6 +12,7 @@ import { SBChatMessage, SBChatMessagePart, SBChatMessageToolTypes, + SearchScope, Source, } from "./types" @@ -172,7 +173,7 @@ export const addLineNumbers = (source: string, lineOffset = 1) => { return source.split('\n').map((line, index) => `${index + lineOffset}:${line}`).join('\n'); } -export const createUIMessage = (text: string, mentions: MentionData[], selectedRepos: string[], selectedReposets: string[]): CreateUIMessage => { +export const createUIMessage = (text: string, mentions: MentionData[], selectedSearchScopes: SearchScope[]): CreateUIMessage => { // Converts applicable mentions into sources. const sources: Source[] = mentions .map((mention) => { @@ -205,8 +206,7 @@ export const createUIMessage = (text: string, mentions: MentionData[], selectedR })) as UIMessagePart<{ source: Source }, SBChatMessageToolTypes>[], ], metadata: { - selectedRepos, - selectedReposets, + selectedSearchScopes, }, } }