Search scope refactor (#405)

* new demo card ui

* rename search context to search scope

* rename everything to use search scope

* add changelog entry
This commit is contained in:
Michael Sukkarieh 2025-07-28 18:12:21 -07:00 committed by GitHub
parent be9979f18a
commit 6662d20ee8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 396 additions and 465 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Add search context to ask sourcebot context selector. [#397](https://github.com/sourcebot-dev/sourcebot/pull/397) - Add search context to ask sourcebot context selector. [#397](https://github.com/sourcebot-dev/sourcebot/pull/397)
- Add ability to include/exclude connection in search context. [#399](https://github.com/sourcebot-dev/sourcebot/pull/399) - Add ability to include/exclude connection in search context. [#399](https://github.com/sourcebot-dev/sourcebot/pull/399)
- Search context refactor to search scope and demo card UI changes. [#405](https://github.com/sourcebot-dev/sourcebot/pull/405)
### Fixed ### Fixed
- Fixed multiple writes race condition on config file watcher. [#398](https://github.com/sourcebot-dev/sourcebot/pull/398) - Fixed multiple writes race condition on config file watcher. [#398](https://github.com/sourcebot-dev/sourcebot/pull/398)

View file

@ -2,13 +2,12 @@
import { ResizablePanel } from '@/components/ui/resizable'; import { ResizablePanel } from '@/components/ui/resizable';
import { ChatThread } from '@/features/chat/components/chatThread'; 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 { RepositoryQuery, SearchContextQuery } from '@/lib/types';
import { CreateUIMessage } from 'ai'; import { CreateUIMessage } from 'ai';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useChatId } from '../../useChatId'; import { useChatId } from '../../useChatId';
import { ContextItem } from '@/features/chat/components/chatBox/contextSelector';
interface ChatThreadPanelProps { interface ChatThreadPanelProps {
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
@ -33,33 +32,12 @@ export const ChatThreadPanel = ({
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [inputMessage, setInputMessage] = useState<CreateUIMessage<SBChatMessage> | undefined>(undefined); const [inputMessage, setInputMessage] = useState<CreateUIMessage<SBChatMessage> | undefined>(undefined);
// Use the last user's last message to determine what repos and contexts we should select by default. // 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 lastUserMessage = messages.findLast((message) => message.role === "user");
const defaultSelectedRepos = lastUserMessage?.metadata?.selectedRepos ?? []; const defaultSelectedSearchScopes = lastUserMessage?.metadata?.selectedSearchScopes ?? [];
const defaultSelectedContexts = lastUserMessage?.metadata?.selectedContexts ?? []; const [selectedSearchScopes, setSelectedSearchScopes] = useState<SearchScope[]>(defaultSelectedSearchScopes);
const [selectedItems, setSelectedItems] = useState<ContextItem[]>([
...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 || ''
};
}),
...defaultSelectedContexts.map(contextName => {
const context = searchContexts.find(c => c.name === contextName);
return {
type: 'context' as const,
value: contextName,
name: contextName,
repoCount: context?.repoNames.length || 0
};
})
]);
useEffect(() => { useEffect(() => {
const setChatState = searchParams.get(SET_CHAT_STATE_QUERY_PARAM); const setChatState = searchParams.get(SET_CHAT_STATE_QUERY_PARAM);
if (!setChatState) { if (!setChatState) {
@ -67,28 +45,9 @@ export const ChatThreadPanel = ({
} }
try { try {
const { inputMessage, selectedRepos, selectedContexts } = JSON.parse(setChatState) as SetChatStatePayload; const { inputMessage, selectedSearchScopes } = JSON.parse(setChatState) as SetChatStatePayload;
setInputMessage(inputMessage); setInputMessage(inputMessage);
setSelectedItems([ setSelectedSearchScopes(selectedSearchScopes);
...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 || ''
};
}),
...selectedContexts.map(contextName => {
const context = searchContexts.find(c => c.name === contextName);
return {
type: 'context' as const,
value: contextName,
name: contextName,
repoCount: context?.repoNames.length || 0
};
})
]);
} catch { } catch {
console.error('Invalid message in URL'); console.error('Invalid message in URL');
} }
@ -97,7 +56,7 @@ export const ChatThreadPanel = ({
const newSearchParams = new URLSearchParams(searchParams.toString()); const newSearchParams = new URLSearchParams(searchParams.toString());
newSearchParams.delete(SET_CHAT_STATE_QUERY_PARAM); newSearchParams.delete(SET_CHAT_STATE_QUERY_PARAM);
router.replace(`?${newSearchParams.toString()}`, { scroll: false }); router.replace(`?${newSearchParams.toString()}`, { scroll: false });
}, [searchParams, router, repos, searchContexts]); }, [searchParams, router]);
return ( return (
<ResizablePanel <ResizablePanel
@ -113,8 +72,8 @@ export const ChatThreadPanel = ({
languageModels={languageModels} languageModels={languageModels}
repos={repos} repos={repos}
searchContexts={searchContexts} searchContexts={searchContexts}
selectedItems={selectedItems} selectedSearchScopes={selectedSearchScopes}
onSelectedItemsChange={setSelectedItems} onSelectedSearchScopesChange={setSelectedSearchScopes}
isChatReadonly={isChatReadonly} isChatReadonly={isChatReadonly}
/> />
</div> </div>

View file

@ -5,12 +5,11 @@ import { ChatBox } from "@/features/chat/components/chatBox";
import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar"; import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar";
import { CustomSlateEditor } from "@/features/chat/customSlateEditor"; import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread"; 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 { RepositoryQuery, SearchContextQuery } from "@/lib/types";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Descendant } from "slate"; import { Descendant } from "slate";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import { ContextItem } from "@/features/chat/components/chatBox/contextSelector";
interface NewChatPanelProps { interface NewChatPanelProps {
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
@ -25,13 +24,13 @@ export const NewChatPanel = ({
searchContexts, searchContexts,
order, order,
}: NewChatPanelProps) => { }: NewChatPanelProps) => {
const [selectedItems, setSelectedItems] = useLocalStorage<ContextItem[]>("selectedContextItems", [], { initializeWithValue: false }); const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
const { createNewChatThread, isLoading } = useCreateNewChatThread(); const { createNewChatThread, isLoading } = useCreateNewChatThread();
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
const onSubmit = useCallback((children: Descendant[]) => { const onSubmit = useCallback((children: Descendant[]) => {
createNewChatThread(children, selectedItems); createNewChatThread(children, selectedSearchScopes);
}, [createNewChatThread, selectedItems]); }, [createNewChatThread, selectedSearchScopes]);
return ( return (
@ -50,7 +49,7 @@ export const NewChatPanel = ({
preferredSuggestionsBoxPlacement="bottom-start" preferredSuggestionsBoxPlacement="bottom-start"
isRedirecting={isLoading} isRedirecting={isLoading}
languageModels={languageModels} languageModels={languageModels}
selectedItems={selectedItems} selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts} searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />
@ -59,8 +58,8 @@ export const NewChatPanel = ({
languageModels={languageModels} languageModels={languageModels}
repos={repos} repos={repos}
searchContexts={searchContexts} searchContexts={searchContexts}
selectedItems={selectedItems} selectedSearchScopes={selectedSearchScopes}
onSelectedItemsChange={setSelectedItems} onSelectedSearchScopesChange={setSelectedSearchScopes}
isContextSelectorOpen={isContextSelectorOpen} isContextSelectorOpen={isContextSelectorOpen}
onContextSelectorOpenChanged={setIsContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />

View file

@ -3,13 +3,12 @@
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ChatBox } from "@/features/chat/components/chatBox"; import { ChatBox } from "@/features/chat/components/chatBox";
import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar"; 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 { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
import { useState } from "react"; import { useState } from "react";
import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar"; import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import { ContextItem } from "@/features/chat/components/chatBox/contextSelector";
import { DemoExamples } from "@/types"; import { DemoExamples } from "@/types";
import { AskSourcebotDemoCards } from "./askSourcebotDemoCards"; import { AskSourcebotDemoCards } from "./askSourcebotDemoCards";
@ -34,7 +33,7 @@ export const AgenticSearch = ({
demoExamples, demoExamples,
}: AgenticSearchProps) => { }: AgenticSearchProps) => {
const { createNewChatThread, isLoading } = useCreateNewChatThread(); const { createNewChatThread, isLoading } = useCreateNewChatThread();
const [selectedItems, setSelectedItems] = useLocalStorage<ContextItem[]>("selectedContextItems", [], { initializeWithValue: false }); const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
return ( return (
@ -42,12 +41,12 @@ export const AgenticSearch = ({
<div className="mt-4 w-full border rounded-md shadow-sm max-w-[800px]"> <div className="mt-4 w-full border rounded-md shadow-sm max-w-[800px]">
<ChatBox <ChatBox
onSubmit={(children) => { onSubmit={(children) => {
createNewChatThread(children, selectedItems); createNewChatThread(children, selectedSearchScopes);
}} }}
className="min-h-[50px]" className="min-h-[50px]"
isRedirecting={isLoading} isRedirecting={isLoading}
languageModels={languageModels} languageModels={languageModels}
selectedItems={selectedItems} selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts} searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />
@ -58,8 +57,8 @@ export const AgenticSearch = ({
languageModels={languageModels} languageModels={languageModels}
repos={repos} repos={repos}
searchContexts={searchContexts} searchContexts={searchContexts}
selectedItems={selectedItems} selectedSearchScopes={selectedSearchScopes}
onSelectedItemsChange={setSelectedItems} onSelectedSearchScopesChange={setSelectedSearchScopes}
isContextSelectorOpen={isContextSelectorOpen} isContextSelectorOpen={isContextSelectorOpen}
onContextSelectorOpenChanged={setIsContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />
@ -74,10 +73,6 @@ export const AgenticSearch = ({
{demoExamples && ( {demoExamples && (
<AskSourcebotDemoCards <AskSourcebotDemoCards
demoExamples={demoExamples} demoExamples={demoExamples}
selectedItems={selectedItems}
setSelectedItems={setSelectedItems}
searchContexts={searchContexts}
repos={repos}
/> />
)} )}
</div > </div >

View file

@ -1,32 +1,25 @@
'use client'; 'use client';
import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { Search, LibraryBigIcon, Code, Layers } from "lucide-react"; import { Search, LibraryBigIcon, Code, Info } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
import { ContextItem, RepoContextItem, SearchContextItem } from "@/features/chat/components/chatBox/contextSelector"; import { DemoExamples, DemoSearchExample, DemoSearchScope } from "@/types";
import { DemoExamples, DemoSearchExample, DemoSearchContextExample, DemoSearchContext } from "@/types";
import { cn, getCodeHostIcon } from "@/lib/utils"; import { cn, getCodeHostIcon } from "@/lib/utils";
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { SearchScopeInfoCard } from "@/components/searchScopeInfoCard";
interface AskSourcebotDemoCardsProps { interface AskSourcebotDemoCardsProps {
demoExamples: DemoExamples; demoExamples: DemoExamples;
selectedItems: ContextItem[];
setSelectedItems: (items: ContextItem[]) => void;
searchContexts: SearchContextQuery[];
repos: RepositoryQuery[];
} }
export const AskSourcebotDemoCards = ({ export const AskSourcebotDemoCards = ({
demoExamples, demoExamples,
selectedItems,
setSelectedItems,
searchContexts,
repos,
}: AskSourcebotDemoCardsProps) => { }: AskSourcebotDemoCardsProps) => {
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const [selectedFilterSearchScope, setSelectedFilterSearchScope] = useState<number | null>(null);
const handleExampleClick = (example: DemoSearchExample) => { const handleExampleClick = (example: DemoSearchExample) => {
captureEvent('wa_demo_search_example_card_pressed', { captureEvent('wa_demo_search_example_card_pressed', {
@ -39,87 +32,37 @@ export const AskSourcebotDemoCards = ({
} }
} }
const getContextIcon = (context: DemoSearchContext, size: number = 20) => { const getSearchScopeIcon = (searchScope: DemoSearchScope, size: number = 20, isSelected: boolean = false) => {
const sizeClass = size === 12 ? "h-3 w-3" : "h-5 w-5"; const sizeClass = size === 12 ? "h-3 w-3" : "h-5 w-5";
const colorClass = isSelected ? "text-primary-foreground" : "text-muted-foreground";
if (context.type === "set") { if (searchScope.type === "reposet") {
return <LibraryBigIcon className={cn(sizeClass, "text-muted-foreground")} />; return <LibraryBigIcon className={cn(sizeClass, colorClass)} />;
} }
if (context.codeHostType) { if (searchScope.codeHostType) {
const codeHostIcon = getCodeHostIcon(context.codeHostType); const codeHostIcon = getCodeHostIcon(searchScope.codeHostType);
if (codeHostIcon) { if (codeHostIcon) {
// When selected, icons need to match the inverted badge colors
// In light mode selected: light icon on dark bg (invert)
// In dark mode selected: dark icon on light bg (no invert, override dark:invert)
const selectedIconClass = isSelected
? "invert dark:invert-0"
: codeHostIcon.className;
return ( return (
<Image <Image
src={codeHostIcon.src} src={codeHostIcon.src}
alt={`${context.codeHostType} icon`} alt={`${searchScope.codeHostType} icon`}
width={size} width={size}
height={size} height={size}
className={cn(sizeClass, codeHostIcon.className)} className={cn(sizeClass, selectedIconClass)}
/> />
); );
} }
} }
return <Code className={cn(sizeClass, "text-muted-foreground")} />; return <Code className={cn(sizeClass, colorClass)} />;
}
const handleContextClick = (demoSearchContexts: DemoSearchContext[], contextExample: DemoSearchContextExample) => {
const context = demoSearchContexts.find((context) => context.id === contextExample.searchContext)
if (!context) {
console.error(`Search context ${contextExample.searchContext} not found on handleContextClick`);
return;
}
captureEvent('wa_demo_search_context_card_pressed', {
contextType: context.type,
contextName: context.value,
contextDisplayName: context.displayName,
});
const isDemoMode = process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo";
const isSelected = selectedItems.some((item) => item.value === context.value);
if (isSelected) {
setSelectedItems(selectedItems.filter((item) => item.value !== context.value));
return;
}
const getNewSelectedItem = (): ContextItem | null => {
if (context.type === "set") {
const searchContext = searchContexts.find((item) => item.name === context.value);
if (!searchContext) {
console.error(`Search context ${context.value} not found on handleContextClick`);
return null;
}
return {
type: 'context',
value: context.value,
name: context.displayName,
repoCount: searchContext.repoNames.length
} as SearchContextItem;
} else {
const repo = repos.find((repo) => repo.repoName === context.value);
if (!repo) {
console.error(`Repo ${context.value} not found on handleContextClick`);
return null;
}
return {
type: 'repo',
value: context.value,
name: context.displayName,
codeHostType: repo.codeHostType
} as RepoContextItem;
}
}
const newSelectedItem = getNewSelectedItem();
if (newSelectedItem) {
setSelectedItems(isDemoMode ? [newSelectedItem] : [...selectedItems, newSelectedItem]);
} else {
console.error(`No new selected item found on handleContextClick`);
}
} }
return ( return (
@ -139,110 +82,91 @@ export const AskSourcebotDemoCards = ({
</p> </p>
)} )}
<div className="w-full mt-8 space-y-12 px-4 max-w-[1000px]"> <div className="w-full mt-16 space-y-12 px-4 max-w-[1000px]">
{/* Search Context Row */}
<div className="space-y-4">
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-2 mb-2">
<Layers className="h-5 w-5 text-muted-foreground" />
<h3 className="text-lg font-semibold">Search Contexts</h3>
</div>
<p className="text-sm text-muted-foreground">Select the context you want to ask questions about</p>
</div>
<div className="flex flex-wrap justify-center gap-3">
{demoExamples.searchContextExamples.map((contextExample) => {
const context = demoExamples.searchContexts.find((context) => context.id === contextExample.searchContext)
if (!context) {
console.error(`Search context ${contextExample.searchContext} not found on handleContextClick`);
return null;
}
const isSelected = selectedItems.some(
(selected) => (selected.type === 'context' && selected.value === context.value) ||
(selected.type === 'repo' && selected.value === context.value)
);
const searchContext = searchContexts.find((item) => item.name === context.value);
const numRepos = searchContext ? searchContext.repoNames.length : undefined;
return (
<Card
key={context.value}
className={`cursor-pointer transition-all duration-200 hover:shadow-md hover:scale-105 group w-full max-w-[280px] ${isSelected ? "border-primary bg-primary/5 shadow-sm" : "hover:border-primary/50"
}`}
onClick={() => handleContextClick(demoExamples.searchContexts, contextExample)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div
className={`flex-shrink-0 p-2 rounded-lg transition-transform group-hover:scale-105`}
>
{getContextIcon(context)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4
className={`font-medium text-sm transition-colors ${isSelected ? "text-primary" : "group-hover:text-primary"
}`}
>
{context.displayName}
</h4>
{numRepos && (
<Badge className="text-[10px] px-1.5 py-0.5 h-4">
{numRepos} repos
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{contextExample.description}</p>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
{/* Example Searches Row */} {/* Example Searches Row */}
<div className="space-y-4"> <div className="space-y-4">
<div className="text-center mb-8"> <div className="text-center mb-6">
<div className="flex items-center justify-center gap-2 mb-2"> <div className="flex items-center justify-center gap-3 mb-4">
<Search className="h-5 w-5 text-muted-foreground" /> <Search className="h-7 w-7 text-muted-foreground" />
<h3 className="text-lg font-semibold">Community Ask Results</h3> <h3 className="text-2xl font-bold">Community Ask Results</h3>
</div> </div>
<p className="text-sm text-muted-foreground">Check out these featured ask results from the community</p>
</div> </div>
{/* Search Scope Filter */}
<div className="flex flex-wrap items-center justify-center gap-2 mb-6">
<div className="flex items-center gap-2 mr-2">
<div className="relative group">
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10 pointer-events-none">
<SearchScopeInfoCard />
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-border"></div>
</div>
</div>
<span className="text-sm font-medium text-muted-foreground">Search Scope:</span>
</div>
<Badge
variant={selectedFilterSearchScope === null ? "default" : "secondary"}
className={`cursor-pointer transition-all duration-200 hover:shadow-sm ${selectedFilterSearchScope === null ? "bg-primary text-primary-foreground" : "hover:bg-secondary/80"
}`}
onClick={() => {
setSelectedFilterSearchScope(null);
}}
>
All
</Badge>
{demoExamples.searchScopes.map((searchScope) => (
<Badge
key={searchScope.id}
variant={selectedFilterSearchScope === searchScope.id ? "default" : "secondary"}
className={`cursor-pointer transition-all duration-200 hover:shadow-sm flex items-center gap-1 ${selectedFilterSearchScope === searchScope.id ? "bg-primary text-primary-foreground" : "hover:bg-secondary/80"
}`}
onClick={() => {
setSelectedFilterSearchScope(searchScope.id);
}}
>
{getSearchScopeIcon(searchScope, 12, selectedFilterSearchScope === searchScope.id)}
{searchScope.displayName}
</Badge>
))}
</div>
<div className="flex flex-wrap justify-center gap-3"> <div className="flex flex-wrap justify-center gap-3">
{demoExamples.searchExamples.map((example) => { {demoExamples.searchExamples
const searchContexts = demoExamples.searchContexts.filter((context) => example.searchContext.includes(context.id)) .filter((example) => {
return ( if (selectedFilterSearchScope === null) return true;
<Card return example.searchScopes.includes(selectedFilterSearchScope);
key={example.url} })
className="cursor-pointer transition-all duration-200 hover:shadow-md hover:scale-105 hover:border-primary/50 group w-full max-w-[350px]" .map((example) => {
onClick={() => handleExampleClick(example)} const searchScopes = demoExamples.searchScopes.filter((searchScope) => example.searchScopes.includes(searchScope.id))
> return (
<CardContent className="p-4"> <Card
<div className="space-y-3"> key={example.url}
<div className="flex items-center justify-between"> className="cursor-pointer transition-all duration-200 hover:shadow-md hover:scale-105 hover:border-primary/50 group w-full max-w-[350px]"
{searchContexts.map((context) => ( onClick={() => handleExampleClick(example)}
<Badge key={context.value} variant="secondary" className="text-[10px] px-1.5 py-0.5 h-4 flex items-center gap-1"> >
{getContextIcon(context, 12)} <CardContent className="p-4">
{context.displayName} <div className="space-y-3">
</Badge> <div className="flex items-center justify-between">
))} {searchScopes.map((searchScope) => (
<Badge key={searchScope.value} variant="secondary" className="text-[10px] px-1.5 py-0.5 h-4 flex items-center gap-1">
{getSearchScopeIcon(searchScope, 12)}
{searchScope.displayName}
</Badge>
))}
</div>
<div className="space-y-1">
<h4 className="font-semibold text-sm group-hover:text-primary transition-colors line-clamp-2">
{example.title}
</h4>
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">
{example.description}
</p>
</div>
</div> </div>
<div className="space-y-1"> </CardContent>
<h4 className="font-semibold text-sm group-hover:text-primary transition-colors line-clamp-2"> </Card>
{example.title} )
</h4> })}
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">
{example.description}
</p>
</div>
</div>
</CardContent>
</Card>
)
})}
</div> </div>
</div> </div>
</div> </div>

View file

@ -2,7 +2,7 @@ import { sew, withAuth, withOrgMembership } from "@/actions";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { _getConfiguredLanguageModelsFull, updateChatMessages, updateChatName } from "@/features/chat/actions"; import { _getConfiguredLanguageModelsFull, updateChatMessages, updateChatName } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent"; 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 { getAnswerPartFromAssistantMessage } from "@/features/chat/utils";
import { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
import { notFound, schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { notFound, schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
@ -64,12 +64,11 @@ export async function POST(req: Request) {
return serviceErrorResponse(schemaValidationError(parsed.error)); return serviceErrorResponse(schemaValidationError(parsed.error));
} }
const { messages, id, selectedRepos, selectedContexts, languageModelId } = parsed.data; const { messages, id, selectedSearchScopes, languageModelId } = parsed.data;
const response = await chatHandler({ const response = await chatHandler({
messages, messages,
id, id,
selectedRepos, selectedSearchScopes,
selectedContexts,
languageModelId, languageModelId,
}, domain); }, domain);
@ -93,12 +92,11 @@ const mergeStreamAsync = async (stream: StreamTextResult<any, any>, writer: UIMe
interface ChatHandlerProps { interface ChatHandlerProps {
messages: SBChatMessage[]; messages: SBChatMessage[];
id: string; id: string;
selectedRepos: string[]; selectedSearchScopes: SearchScope[];
selectedContexts?: string[];
languageModelId: string; languageModelId: string;
} }
const chatHandler = ({ messages, id, selectedRepos, selectedContexts, languageModelId }: ChatHandlerProps, domain: string) => sew(async () => const chatHandler = ({ messages, id, selectedSearchScopes, languageModelId }: ChatHandlerProps, domain: string) => sew(async () =>
withAuth((userId) => withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => { withOrgMembership(userId, domain, async ({ org }) => {
const chat = await prisma.chat.findUnique({ const chat = await prisma.chat.findUnique({
@ -188,26 +186,30 @@ const chatHandler = ({ messages, id, selectedRepos, selectedContexts, languageMo
const startTime = new Date(); const startTime = new Date();
// Expand search contexts to repos const expandedReposArrays = await Promise.all(selectedSearchScopes.map(async (scope) => {
let expandedRepos = [...selectedRepos]; if (scope.type === 'repo') {
if (selectedContexts && selectedContexts.length > 0) { return [scope.value];
const searchContexts = await prisma.searchContext.findMany({ }
where: {
orgId: org.id, if (scope.type === 'reposet') {
name: { in: selectedContexts } const reposet = await prisma.searchContext.findFirst({
}, where: {
include: { orgId: org.id,
repos: true name: scope.value
},
include: {
repos: true
}
});
if (reposet) {
return reposet.repos.map(repo => repo.name);
} }
}); }
const contextRepos = searchContexts.flatMap(context => return [];
context.repos.map(repo => repo.name) }));
); const expandedRepos = expandedReposArrays.flat();
// Combine and deduplicate repos
expandedRepos = Array.from(new Set([...selectedRepos, ...contextRepos]));
}
const researchStream = await createAgentStream({ const researchStream = await createAgentStream({
model, model,
@ -215,7 +217,7 @@ const chatHandler = ({ messages, id, selectedRepos, selectedContexts, languageMo
headers, headers,
inputMessages: messageHistory, inputMessages: messageHistory,
inputSources: sources, inputSources: sources,
selectedRepos: expandedRepos, searchScopeRepoNames: expandedRepos,
onWriteSource: (source) => { onWriteSource: (source) => {
writer.write({ writer.write({
type: 'data-source', type: 'data-source',
@ -241,6 +243,7 @@ const chatHandler = ({ messages, id, selectedRepos, selectedContexts, languageMo
totalOutputTokens: totalUsage.outputTokens, totalOutputTokens: totalUsage.outputTokens,
totalResponseTimeMs: new Date().getTime() - startTime.getTime(), totalResponseTimeMs: new Date().getTime() - startTime.getTime(),
modelName: languageModelConfig.displayName ?? languageModelConfig.model, modelName: languageModelConfig.displayName ?? languageModelConfig.model,
selectedSearchScopes,
traceId, traceId,
} }
}) })

View file

@ -0,0 +1,15 @@
import { AtSignIcon } from "lucide-react";
export const AtMentionInfoCard = () => {
return (
<div className="bg-popover border border-border rounded-lg shadow-lg p-4 w-80 max-w-[90vw]">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/50">
<AtSignIcon className="h-4 w-4 text-primary" />
<h4 className="text-sm font-semibold text-popover-foreground">Mention</h4>
</div>
<div className="text-sm text-popover-foreground leading-relaxed">
When asking Sourcebot a question, you can @ mention files to include them in the context of the search.
</div>
</div>
);
};

View file

@ -0,0 +1,41 @@
import Image from "next/image";
import { LibraryBigIcon, Code, ScanSearchIcon } from "lucide-react";
import { cn, getCodeHostIcon } from "@/lib/utils";
export const SearchScopeInfoCard = () => {
return (
<div className="bg-popover border border-border rounded-lg shadow-lg p-4 w-80 max-w-[90vw]">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/50">
<ScanSearchIcon className="h-4 w-4 text-primary" />
<h4 className="text-sm font-semibold text-popover-foreground">Search Scope</h4>
</div>
<div className="text-sm text-popover-foreground leading-relaxed">
When asking Sourcebot a question, you can select one or more scopes to constrain the search.
There are two different types of search scopes:
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
{(() => {
const githubIcon = getCodeHostIcon("github");
return githubIcon ? (
<Image
src={githubIcon.src}
alt="GitHub icon"
width={16}
height={16}
className={cn("h-4 w-4 flex-shrink-0", githubIcon.className)}
/>
) : (
<Code className="h-4 w-4 text-muted-foreground flex-shrink-0" />
);
})()}
<span><strong>Repository</strong>: A single repository, indicated by the code host icon.</span>
</div>
<div className="flex items-center gap-2">
<LibraryBigIcon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span><strong>Reposet</strong>: A set of repositories, indicated by the library icon.</span>
</div>
</div>
</div>
</div>
);
};

View file

@ -16,7 +16,7 @@ interface AgentOptions {
model: LanguageModel; model: LanguageModel;
providerOptions?: ProviderOptions; providerOptions?: ProviderOptions;
headers?: Record<string, string>; headers?: Record<string, string>;
selectedRepos: string[]; searchScopeRepoNames: string[];
inputMessages: ModelMessage[]; inputMessages: ModelMessage[];
inputSources: Source[]; inputSources: Source[];
onWriteSource: (source: Source) => void; onWriteSource: (source: Source) => void;
@ -35,12 +35,12 @@ export const createAgentStream = async ({
headers, headers,
inputMessages, inputMessages,
inputSources, inputSources,
selectedRepos, searchScopeRepoNames,
onWriteSource, onWriteSource,
traceId, traceId,
}: AgentOptions) => { }: AgentOptions) => {
const baseSystemPrompt = createBaseSystemPrompt({ const baseSystemPrompt = createBaseSystemPrompt({
selectedRepos, searchScopeRepoNames,
}); });
const stream = streamText({ const stream = streamText({
@ -50,7 +50,7 @@ export const createAgentStream = async ({
system: baseSystemPrompt, system: baseSystemPrompt,
messages: inputMessages, messages: inputMessages,
tools: { tools: {
[toolNames.searchCode]: createCodeSearchTool(selectedRepos), [toolNames.searchCode]: createCodeSearchTool(searchScopeRepoNames),
[toolNames.readFiles]: readFilesTool, [toolNames.readFiles]: readFilesTool,
[toolNames.findSymbolReferences]: findSymbolReferencesTool, [toolNames.findSymbolReferences]: findSymbolReferencesTool,
[toolNames.findSymbolDefinitions]: findSymbolDefinitionsTool, [toolNames.findSymbolDefinitions]: findSymbolDefinitionsTool,
@ -150,11 +150,11 @@ export const createAgentStream = async ({
} }
interface BaseSystemPromptOptions { interface BaseSystemPromptOptions {
selectedRepos: string[]; searchScopeRepoNames: string[];
} }
export const createBaseSystemPrompt = ({ export const createBaseSystemPrompt = ({
selectedRepos, searchScopeRepoNames,
}: BaseSystemPromptOptions) => { }: BaseSystemPromptOptions) => {
return ` 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. 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:
<available_repositories> <available_repositories>
The user has selected the following repositories for analysis: The user has selected the following repositories for analysis:
${selectedRepos.map(repo => `- ${repo}`).join('\n')} ${searchScopeRepoNames.map(repo => `- ${repo}`).join('\n')}
</available_repositories> </available_repositories>
<research_phase_instructions> <research_phase_instructions>

View file

@ -3,7 +3,7 @@
import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; 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 { insertMention, slateContentToString } from "@/features/chat/utils";
import { cn, IS_MAC } from "@/lib/utils"; import { cn, IS_MAC } from "@/lib/utils";
import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react"; import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react";
@ -18,7 +18,6 @@ import { Suggestion } from "./types";
import { useSuggestionModeAndQuery } from "./useSuggestionModeAndQuery"; import { useSuggestionModeAndQuery } from "./useSuggestionModeAndQuery";
import { useSuggestionsData } from "./useSuggestionsData"; import { useSuggestionsData } from "./useSuggestionsData";
import { useToast } from "@/components/hooks/use-toast"; import { useToast } from "@/components/hooks/use-toast";
import { ContextItem } from "./contextSelector";
import { SearchContextQuery } from "@/lib/types"; import { SearchContextQuery } from "@/lib/types";
interface ChatBoxProps { interface ChatBoxProps {
@ -29,7 +28,7 @@ interface ChatBoxProps {
isRedirecting?: boolean; isRedirecting?: boolean;
isGenerating?: boolean; isGenerating?: boolean;
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
selectedItems: ContextItem[]; selectedSearchScopes: SearchScope[];
searchContexts: SearchContextQuery[]; searchContexts: SearchContextQuery[];
onContextSelectorOpenChanged: (isOpen: boolean) => void; onContextSelectorOpenChanged: (isOpen: boolean) => void;
} }
@ -42,7 +41,7 @@ export const ChatBox = ({
isRedirecting, isRedirecting,
isGenerating, isGenerating,
languageModels, languageModels,
selectedItems, selectedSearchScopes,
searchContexts, searchContexts,
onContextSelectorOpenChanged, onContextSelectorOpenChanged,
}: ChatBoxProps) => { }: ChatBoxProps) => {
@ -53,15 +52,15 @@ export const ChatBox = ({
const { suggestions, isLoading } = useSuggestionsData({ const { suggestions, isLoading } = useSuggestionsData({
suggestionMode, suggestionMode,
suggestionQuery, suggestionQuery,
selectedRepos: selectedItems.map((item) => { selectedRepos: selectedSearchScopes.map((item) => {
if (item.type === 'repo') { if (item.type === 'repo') {
return [item.value]; return [item.value];
} }
if (item.type === 'context') { if (item.type === 'reposet') {
const context = searchContexts.find((context) => context.name === item.value); const reposet = searchContexts.find((reposet) => reposet.name === item.value);
if (context) { if (reposet) {
return context.repoNames; return reposet.repoNames;
} }
} }
@ -130,7 +129,7 @@ export const ChatBox = ({
} }
} }
if (selectedItems.length === 0) { if (selectedSearchScopes.length === 0) {
return { return {
isSubmitDisabled: true, isSubmitDisabled: true,
isSubmitDisabledReason: "no-repos-selected", isSubmitDisabledReason: "no-repos-selected",
@ -154,7 +153,7 @@ export const ChatBox = ({
editor.children, editor.children,
isRedirecting, isRedirecting,
isGenerating, isGenerating,
selectedItems.length, selectedSearchScopes.length,
selectedLanguageModel, selectedLanguageModel,
]) ])
@ -162,7 +161,7 @@ export const ChatBox = ({
if (isSubmitDisabled) { if (isSubmitDisabled) {
if (isSubmitDisabledReason === "no-repos-selected") { if (isSubmitDisabledReason === "no-repos-selected") {
toast({ toast({
description: "⚠️ You must select at least one search context", description: "⚠️ You must select at least one search scope",
variant: "destructive", variant: "destructive",
}); });
onContextSelectorOpenChanged(true); onContextSelectorOpenChanged(true);
@ -284,7 +283,7 @@ export const ChatBox = ({
> >
<Editable <Editable
className="w-full focus-visible:outline-none focus-visible:ring-0 bg-background text-base disabled:cursor-not-allowed disabled:opacity-50 md:text-sm" className="w-full focus-visible:outline-none focus-visible:ring-0 bg-background text-base disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
placeholder="Ask questions about the selected search contexts. @mention files to refine your query." placeholder="Ask a question about the selected search scopes. @mention files to refine your query."
renderElement={renderElement} renderElement={renderElement}
renderLeaf={renderLeaf} renderLeaf={renderLeaf}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@ -339,7 +338,7 @@ export const ChatBox = ({
<TooltipContent> <TooltipContent>
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<TriangleAlertIcon className="h-4 w-4 text-warning mr-1" /> <TriangleAlertIcon className="h-4 w-4 text-warning mr-1" />
<span className="text-destructive">You must select at least one search context</span> <span className="text-destructive">You must select at least one search scope</span>
</div> </div>
</TooltipContent> </TooltipContent>
)} )}

View file

@ -1,25 +1,25 @@
'use client'; 'use client';
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; 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 { RepositoryQuery, SearchContextQuery } from "@/lib/types";
import { AtSignIcon } from "lucide-react"; import { AtSignIcon } from "lucide-react";
import { useCallback } from "react"; import { useCallback } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { ReactEditor, useSlate } from "slate-react"; import { ReactEditor, useSlate } from "slate-react";
import { useSelectedLanguageModel } from "../../useSelectedLanguageModel"; import { useSelectedLanguageModel } from "../../useSelectedLanguageModel";
import { LanguageModelSelector } from "./languageModelSelector"; import { LanguageModelSelector } from "./languageModelSelector";
import { ContextSelector, type ContextItem } from "./contextSelector"; import { SearchScopeSelector } from "./searchScopeSelector";
import { SearchScopeInfoCard } from "@/components/searchScopeInfoCard";
import { AtMentionInfoCard } from "@/components/atMentionInfoCard";
export interface ChatBoxToolbarProps { export interface ChatBoxToolbarProps {
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
repos: RepositoryQuery[]; repos: RepositoryQuery[];
searchContexts: SearchContextQuery[]; searchContexts: SearchContextQuery[];
selectedItems: ContextItem[]; selectedSearchScopes: SearchScope[];
onSelectedItemsChange: (items: ContextItem[]) => void; onSelectedSearchScopesChange: (items: SearchScope[]) => void;
isContextSelectorOpen: boolean; isContextSelectorOpen: boolean;
onContextSelectorOpenChanged: (isOpen: boolean) => void; onContextSelectorOpenChanged: (isOpen: boolean) => void;
} }
@ -28,8 +28,8 @@ export const ChatBoxToolbar = ({
languageModels, languageModels,
repos, repos,
searchContexts, searchContexts,
selectedItems, selectedSearchScopes,
onSelectedItemsChange, onSelectedSearchScopesChange,
isContextSelectorOpen, isContextSelectorOpen,
onContextSelectorOpenChanged, onContextSelectorOpenChanged,
}: ChatBoxToolbarProps) => { }: ChatBoxToolbarProps) => {
@ -40,15 +40,6 @@ export const ChatBoxToolbar = ({
ReactEditor.focus(editor); ReactEditor.focus(editor);
}, [editor]); }, [editor]);
useHotkeys("alt+mod+p", (e) => {
e.preventDefault();
onAddContext();
}, {
enableOnFormTags: true,
enableOnContentEditable: true,
description: "Add context",
});
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({ const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined, initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
}); });
@ -66,30 +57,25 @@ export const ChatBoxToolbar = ({
<AtSignIcon className="w-4 h-4" /> <AtSignIcon className="w-4 h-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
side="bottom" <AtMentionInfoCard />
className="flex flex-row items-center gap-2"
>
<KeyboardShortcutHint shortcut="⌥ ⌘ P" />
<Separator orientation="vertical" className="h-4" />
<span>Add context</span>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Separator orientation="vertical" className="h-3 mx-1" /> <Separator orientation="vertical" className="h-3 mx-1" />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<ContextSelector <SearchScopeSelector
className="bg-inherit w-fit h-6 min-h-6" className="bg-inherit w-fit h-6 min-h-6"
repos={repos} repos={repos}
searchContexts={searchContexts} searchContexts={searchContexts}
selectedItems={selectedItems} selectedSearchScopes={selectedSearchScopes}
onSelectedItemsChange={onSelectedItemsChange} onSelectedSearchScopesChange={onSelectedSearchScopesChange}
isOpen={isContextSelectorOpen} isOpen={isContextSelectorOpen}
onOpenChanged={onContextSelectorOpenChanged} onOpenChanged={onContextSelectorOpenChanged}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
<span>Search contexts and repositories to scope conversation to.</span> <SearchScopeInfoCard />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{languageModels.length > 0 && ( {languageModels.length > 0 && (

View file

@ -4,13 +4,10 @@ import * as React from "react";
import { import {
CheckIcon, CheckIcon,
ChevronDown, ChevronDown,
FolderIcon, ScanSearchIcon,
LayersIcon,
LibraryBigIcon,
} from "lucide-react"; } 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 { RepositoryQuery, SearchContextQuery } from "@/lib/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -28,44 +25,30 @@ import {
CommandList, CommandList,
CommandSeparator, CommandSeparator,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { RepoSetSearchScope, RepoSearchScope, SearchScope } from "../../types";
import { SearchScopeIcon } from "../searchScopeIcon";
export type RepoContextItem = { interface SearchScopeSelectorProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
type: 'repo';
value: string;
name: string;
codeHostType: string;
}
export type SearchContextItem = {
type: 'context';
value: string;
name: string;
repoCount: number;
}
export type ContextItem = RepoContextItem | SearchContextItem;
interface ContextSelectorProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
repos: RepositoryQuery[]; repos: RepositoryQuery[];
searchContexts: SearchContextQuery[]; searchContexts: SearchContextQuery[];
selectedItems: ContextItem[]; selectedSearchScopes: SearchScope[];
onSelectedItemsChange: (items: ContextItem[]) => void; onSelectedSearchScopesChange: (items: SearchScope[]) => void;
className?: string; className?: string;
isOpen: boolean; isOpen: boolean;
onOpenChanged: (isOpen: boolean) => void; onOpenChanged: (isOpen: boolean) => void;
} }
export const ContextSelector = React.forwardRef< export const SearchScopeSelector = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
ContextSelectorProps SearchScopeSelectorProps
>( >(
( (
{ {
repos, repos,
searchContexts, searchContexts,
onSelectedItemsChange,
className, className,
selectedItems, selectedSearchScopes,
onSelectedSearchScopesChange,
isOpen, isOpen,
onOpenChanged, onOpenChanged,
...props ...props
@ -81,72 +64,62 @@ export const ContextSelector = React.forwardRef<
if (event.key === "Enter") { if (event.key === "Enter") {
onOpenChanged(true); onOpenChanged(true);
} else if (event.key === "Backspace" && !event.currentTarget.value) { } else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedItems = [...selectedItems]; const newSelectedItems = [...selectedSearchScopes];
newSelectedItems.pop(); newSelectedItems.pop();
onSelectedItemsChange(newSelectedItems); onSelectedSearchScopesChange(newSelectedItems);
} }
}; };
const toggleItem = (item: ContextItem) => { const toggleItem = (item: SearchScope) => {
// Store current scroll position before state update // Store current scroll position before state update
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
scrollPosition.current = scrollContainerRef.current.scrollTop; scrollPosition.current = scrollContainerRef.current.scrollTop;
} }
const isSelected = selectedItems.some( const isSelected = selectedSearchScopes.some(
(selected) => selected.type === item.type && selected.value === item.value (selected) => selected.type === item.type && selected.value === item.value
); );
const isDemoMode = process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo"; const newSelectedItems = isSelected ?
selectedSearchScopes.filter(
let newSelectedItems: ContextItem[];
if (isSelected) {
newSelectedItems = selectedItems.filter(
(selected) => !(selected.type === item.type && selected.value === item.value) (selected) => !(selected.type === item.type && selected.value === item.value)
); ) :
} else { [...selectedSearchScopes, item];
// Limit selected context to 1 in demo mode
if (isDemoMode) {
newSelectedItems = [item];
} else {
newSelectedItems = [...selectedItems, item];
}
}
onSelectedItemsChange(newSelectedItems); onSelectedSearchScopesChange(newSelectedItems);
}; };
const handleClear = () => { const handleClear = () => {
onSelectedItemsChange([]); onSelectedSearchScopesChange([]);
}; };
const handleTogglePopover = () => { const handleTogglePopover = () => {
onOpenChanged(!isOpen); onOpenChanged(!isOpen);
}; };
const allItems = React.useMemo(() => { const allSearchScopeItems = React.useMemo(() => {
const contextItems: ContextItem[] = searchContexts.map(context => ({ const repoSetSearchScopeItems: RepoSetSearchScope[] = searchContexts.map(context => ({
type: 'context' as const, type: 'reposet' as const,
value: context.name, value: context.name,
name: context.name, name: context.name,
repoCount: context.repoNames.length repoCount: context.repoNames.length
})); }));
const repoItems: ContextItem[] = repos.map(repo => ({ const repoSearchScopeItems: RepoSearchScope[] = repos.map(repo => ({
type: 'repo' as const, type: 'repo' as const,
value: repo.repoName, value: repo.repoName,
name: repo.repoDisplayName || repo.repoName.split('/').pop() || repo.repoName, name: repo.repoDisplayName || repo.repoName.split('/').pop() || repo.repoName,
codeHostType: repo.codeHostType, codeHostType: repo.codeHostType,
})); }));
return [...contextItems, ...repoItems]; return [...repoSetSearchScopeItems, ...repoSearchScopeItems];
}, [repos, searchContexts]); }, [repos, searchContexts]);
const sortedItems = React.useMemo(() => { const sortedSearchScopeItems = React.useMemo(() => {
return allItems return allSearchScopeItems
.map((item) => ({ .map((item) => ({
item, item,
isSelected: selectedItems.some( isSelected: selectedSearchScopes.some(
(selected) => selected.type === item.type && selected.value === item.value (selected) => selected.type === item.type && selected.value === item.value
) )
})) }))
@ -154,19 +127,19 @@ export const ContextSelector = React.forwardRef<
// Selected items first // Selected items first
if (a.isSelected && !b.isSelected) return -1; if (a.isSelected && !b.isSelected) return -1;
if (!a.isSelected && b.isSelected) return 1; if (!a.isSelected && b.isSelected) return 1;
// Then contexts before repos // Then reposets before repos
if (a.item.type === 'context' && b.item.type === 'repo') return -1; if (a.item.type === 'reposet' && b.item.type === 'repo') return -1;
if (a.item.type === 'repo' && b.item.type === 'context') return 1; if (a.item.type === 'repo' && b.item.type === 'reposet') return 1;
return 0; return 0;
}) })
}, [allItems, selectedItems]); }, [allSearchScopeItems, selectedSearchScopes]);
// Restore scroll position after re-render // Restore scroll position after re-render
React.useEffect(() => { React.useEffect(() => {
if (scrollContainerRef.current && scrollPosition.current > 0) { if (scrollContainerRef.current && scrollPosition.current > 0) {
scrollContainerRef.current.scrollTop = scrollPosition.current; scrollContainerRef.current.scrollTop = scrollPosition.current;
} }
}, [sortedItems]); }, [sortedSearchScopeItems]);
return ( return (
<Popover <Popover
@ -184,14 +157,14 @@ export const ContextSelector = React.forwardRef<
)} )}
> >
<div className="flex items-center justify-between w-full mx-auto"> <div className="flex items-center justify-between w-full mx-auto">
<LayersIcon className="h-4 w-4 text-muted-foreground mr-1" /> <ScanSearchIcon className="h-4 w-4 text-muted-foreground mr-1" />
<span <span
className={cn("text-sm text-muted-foreground mx-1 font-medium")} className={cn("text-sm text-muted-foreground mx-1 font-medium")}
> >
{ {
selectedItems.length === 0 ? `Select context` : selectedSearchScopes.length === 0 ? `Search scopes` :
selectedItems.length === 1 ? selectedItems[0].name : selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name :
`${selectedItems.length} selected` `${selectedSearchScopes.length} selected`
} }
</span> </span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground ml-2" /> <ChevronDown className="h-4 cursor-pointer text-muted-foreground ml-2" />
@ -205,13 +178,13 @@ export const ContextSelector = React.forwardRef<
> >
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search contexts..." placeholder="Search scopes..."
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
/> />
<CommandList ref={scrollContainerRef}> <CommandList ref={scrollContainerRef}>
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>No results found.</CommandEmpty>
<CommandGroup> <CommandGroup>
{sortedItems.map(({ item, isSelected }) => { {sortedSearchScopeItems.map(({ item, isSelected }) => {
return ( return (
<CommandItem <CommandItem
key={`${item.type}-${item.value}`} key={`${item.type}-${item.value}`}
@ -229,31 +202,13 @@ export const ContextSelector = React.forwardRef<
<CheckIcon className="h-4 w-4" /> <CheckIcon className="h-4 w-4" />
</div> </div>
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
{item.type === 'context' ? ( <SearchScopeIcon searchScope={item} />
<LibraryBigIcon className="h-4 w-4 text-muted-foreground" />
) : (
// Render code host icon for repos
(() => {
const codeHostIcon = item.codeHostType ? getCodeHostIcon(item.codeHostType) : null;
return codeHostIcon ? (
<Image
src={codeHostIcon.src}
alt={`${item.codeHostType} icon`}
width={16}
height={16}
className={cn("h-4 w-4", codeHostIcon.className)}
/>
) : (
<FolderIcon className="h-4 w-4 text-muted-foreground" />
);
})()
)}
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium"> <span className="font-medium">
{item.name} {item.name}
</span> </span>
{item.type === 'context' && ( {item.type === 'reposet' && (
<Badge <Badge
variant="default" variant="default"
className="text-[10px] px-1.5 py-0 h-4 bg-primary text-primary-foreground" className="text-[10px] px-1.5 py-0 h-4 bg-primary text-primary-foreground"
@ -269,7 +224,7 @@ export const ContextSelector = React.forwardRef<
})} })}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
{selectedItems.length > 0 && ( {selectedSearchScopes.length > 0 && (
<> <>
<CommandSeparator /> <CommandSeparator />
<CommandItem <CommandItem
@ -287,4 +242,4 @@ export const ContextSelector = React.forwardRef<
} }
); );
ContextSelector.displayName = "ContextSelector"; SearchScopeSelector.displayName = "SearchScopeSelector";

View file

@ -5,14 +5,14 @@ import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { CustomSlateEditor } from '@/features/chat/customSlateEditor'; import { CustomSlateEditor } from '@/features/chat/customSlateEditor';
import { AdditionalChatRequestParams, CustomEditor, LanguageModelInfo, SBChatMessage, Source } from '@/features/chat/types'; import { AdditionalChatRequestParams, CustomEditor, LanguageModelInfo, SBChatMessage, SearchScope, Source } from '@/features/chat/types';
import { createUIMessage, getAllMentionElements, resetEditor, slateContentToString } from '@/features/chat/utils'; import { createUIMessage, getAllMentionElements, resetEditor, slateContentToString } from '@/features/chat/utils';
import { useDomain } from '@/hooks/useDomain'; import { useDomain } from '@/hooks/useDomain';
import { useChat } from '@ai-sdk/react'; import { useChat } from '@ai-sdk/react';
import { CreateUIMessage, DefaultChatTransport } from 'ai'; import { CreateUIMessage, DefaultChatTransport } from 'ai';
import { ArrowDownIcon } from 'lucide-react'; import { ArrowDownIcon } from 'lucide-react';
import { useNavigationGuard } from 'next-navigation-guard'; import { useNavigationGuard } from 'next-navigation-guard';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { Descendant } from 'slate'; import { Descendant } from 'slate';
import { useMessagePairs } from '../../useMessagePairs'; import { useMessagePairs } from '../../useMessagePairs';
import { useSelectedLanguageModel } from '../../useSelectedLanguageModel'; import { useSelectedLanguageModel } from '../../useSelectedLanguageModel';
@ -23,7 +23,6 @@ import { ErrorBanner } from './errorBanner';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { usePrevious } from '@uidotdev/usehooks'; import { usePrevious } from '@uidotdev/usehooks';
import { RepositoryQuery, SearchContextQuery } from '@/lib/types'; import { RepositoryQuery, SearchContextQuery } from '@/lib/types';
import { ContextItem } from '../chatBox/contextSelector';
type ChatHistoryState = { type ChatHistoryState = {
scrollOffset?: number; scrollOffset?: number;
@ -36,8 +35,8 @@ interface ChatThreadProps {
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
repos: RepositoryQuery[]; repos: RepositoryQuery[];
searchContexts: SearchContextQuery[]; searchContexts: SearchContextQuery[];
selectedItems: ContextItem[]; selectedSearchScopes: SearchScope[];
onSelectedItemsChange: (items: ContextItem[]) => void; onSelectedSearchScopesChange: (items: SearchScope[]) => void;
isChatReadonly: boolean; isChatReadonly: boolean;
} }
@ -48,8 +47,8 @@ export const ChatThread = ({
languageModels, languageModels,
repos, repos,
searchContexts, searchContexts,
selectedItems, selectedSearchScopes,
onSelectedItemsChange, onSelectedSearchScopesChange,
isChatReadonly, isChatReadonly,
}: ChatThreadProps) => { }: ChatThreadProps) => {
const domain = useDomain(); const domain = useDomain();
@ -62,12 +61,6 @@ export const ChatThread = ({
const router = useRouter(); const router = useRouter();
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
const { selectedRepos, selectedContexts } = useMemo(() => {
const repos = selectedItems.filter(item => item.type === 'repo').map(item => item.value);
const contexts = selectedItems.filter(item => item.type === 'context').map(item => item.value);
return { selectedRepos: repos, selectedContexts: contexts };
}, [selectedItems]);
// Initial state is from attachments that exist in in the chat history. // Initial state is from attachments that exist in in the chat history.
const [sources, setSources] = useState<Source[]>( const [sources, setSources] = useState<Source[]>(
initialMessages?.flatMap((message) => initialMessages?.flatMap((message) =>
@ -122,12 +115,11 @@ export const ChatThread = ({
_sendMessage(message, { _sendMessage(message, {
body: { body: {
selectedRepos, selectedSearchScopes,
selectedContexts,
languageModelId: selectedLanguageModel.model, languageModelId: selectedLanguageModel.model,
} satisfies AdditionalChatRequestParams, } satisfies AdditionalChatRequestParams,
}); });
}, [_sendMessage, selectedLanguageModel, toast, selectedRepos, selectedContexts]); }, [_sendMessage, selectedLanguageModel, toast, selectedSearchScopes]);
const messagePairs = useMessagePairs(messages); const messagePairs = useMessagePairs(messages);
@ -243,13 +235,13 @@ export const ChatThread = ({
const text = slateContentToString(children); const text = slateContentToString(children);
const mentions = getAllMentionElements(children); const mentions = getAllMentionElements(children);
const message = createUIMessage(text, mentions.map(({ data }) => data), selectedRepos, selectedContexts); const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes);
sendMessage(message); sendMessage(message);
setIsAutoScrollEnabled(true); setIsAutoScrollEnabled(true);
resetEditor(editor); resetEditor(editor);
}, [sendMessage, selectedRepos, selectedContexts]); }, [sendMessage, selectedSearchScopes]);
return ( return (
<> <>
@ -327,7 +319,7 @@ export const ChatThread = ({
isGenerating={status === "streaming" || status === "submitted"} isGenerating={status === "streaming" || status === "submitted"}
onStop={stop} onStop={stop}
languageModels={languageModels} languageModels={languageModels}
selectedItems={selectedItems} selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts} searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />
@ -336,8 +328,8 @@ export const ChatThread = ({
languageModels={languageModels} languageModels={languageModels}
repos={repos} repos={repos}
searchContexts={searchContexts} searchContexts={searchContexts}
selectedItems={selectedItems} selectedSearchScopes={selectedSearchScopes}
onSelectedItemsChange={onSelectedItemsChange} onSelectedSearchScopesChange={onSelectedSearchScopesChange}
isContextSelectorOpen={isContextSelectorOpen} isContextSelectorOpen={isContextSelectorOpen}
onContextSelectorOpenChanged={setIsContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />

View file

@ -4,14 +4,16 @@ import { Card, CardContent } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils'; 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 { MarkdownRenderer } from './markdownRenderer';
import { FindSymbolDefinitionsToolComponent } from './tools/findSymbolDefinitionsToolComponent'; import { FindSymbolDefinitionsToolComponent } from './tools/findSymbolDefinitionsToolComponent';
import { FindSymbolReferencesToolComponent } from './tools/findSymbolReferencesToolComponent'; import { FindSymbolReferencesToolComponent } from './tools/findSymbolReferencesToolComponent';
import { ReadFilesToolComponent } from './tools/readFilesToolComponent'; import { ReadFilesToolComponent } from './tools/readFilesToolComponent';
import { SearchCodeToolComponent } from './tools/searchCodeToolComponent'; import { SearchCodeToolComponent } from './tools/searchCodeToolComponent';
import { SBChatMessageMetadata, SBChatMessagePart } from '../../types'; import { SBChatMessageMetadata, SBChatMessagePart } from '../../types';
import { SearchScopeIcon } from '../searchScopeIcon';
interface DetailsCardProps { interface DetailsCardProps {
@ -61,6 +63,28 @@ export const DetailsCard = ({
{!isStreaming && ( {!isStreaming && (
<> <>
<Separator orientation="vertical" className="h-4" /> <Separator orientation="vertical" className="h-4" />
{metadata?.selectedSearchScopes && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center text-xs cursor-help">
<ScanSearchIcon className="w-3 h-3 mr-1 flex-shrink-0" />
{metadata.selectedSearchScopes.length} search scope{metadata.selectedSearchScopes.length === 1 ? '' : 's'}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="max-w-xs">
<div className="space-y-2">
{metadata.selectedSearchScopes.map((item) => (
<div key={item.value} className="flex items-center gap-2 text-xs">
<SearchScopeIcon searchScope={item} className="h-3 w-3" />
<span>{item.name}</span>
</div>
))}
</div>
</div>
</TooltipContent>
</Tooltip>
)}
{metadata?.modelName && ( {metadata?.modelName && (
<div className="flex items-center text-xs"> <div className="flex items-center text-xs">
<Cpu className="w-3 h-3 mr-1 flex-shrink-0" /> <Cpu className="w-3 h-3 mr-1 flex-shrink-0" />

View file

@ -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 <LibraryBigIcon className={cn(className, "text-muted-foreground flex-shrink-0")} />;
} 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 (
<Image
src={codeHostIcon.src}
alt={`${searchScope.codeHostType} icon`}
width={size}
height={size}
className={cn(className, "flex-shrink-0", codeHostIcon.className)}
/>
);
} else {
return <FolderIcon className={cn(className, "text-muted-foreground flex-shrink-0")} />;
}
}
};

View file

@ -39,6 +39,28 @@ export const referenceSchema = z.discriminatedUnion('type', [
]); ]);
export type Reference = z.infer<typeof referenceSchema>; export type Reference = z.infer<typeof referenceSchema>;
export const repoSearchScopeSchema = z.object({
type: z.literal('repo'),
value: z.string(),
name: z.string(),
codeHostType: z.string(),
});
export type RepoSearchScope = z.infer<typeof repoSearchScopeSchema>;
export const repoSetSearchScopeSchema = z.object({
type: z.literal('reposet'),
value: z.string(),
name: z.string(),
repoCount: z.number(),
});
export type RepoSetSearchScope = z.infer<typeof repoSetSearchScopeSchema>;
export const searchScopeSchema = z.discriminatedUnion('type', [
repoSearchScopeSchema,
repoSetSearchScopeSchema,
]);
export type SearchScope = z.infer<typeof searchScopeSchema>;
export const sbChatMessageMetadataSchema = z.object({ export const sbChatMessageMetadataSchema = z.object({
modelName: z.string().optional(), modelName: z.string().optional(),
totalInputTokens: z.number().optional(), totalInputTokens: z.number().optional(),
@ -50,8 +72,7 @@ export const sbChatMessageMetadataSchema = z.object({
timestamp: z.string(), // ISO date string timestamp: z.string(), // ISO date string
userId: z.string(), userId: z.string(),
})).optional(), })).optional(),
selectedRepos: z.array(z.string()).optional(), selectedSearchScopes: z.array(searchScopeSchema).optional(),
selectedContexts: z.array(z.string()).optional(),
traceId: z.string().optional(), traceId: z.string().optional(),
}); });
@ -139,8 +160,7 @@ export const SET_CHAT_STATE_QUERY_PARAM = 'setChatState';
export type SetChatStatePayload = { export type SetChatStatePayload = {
inputMessage: CreateUIMessage<SBChatMessage>; inputMessage: CreateUIMessage<SBChatMessage>;
selectedRepos: string[]; selectedSearchScopes: SearchScope[];
selectedContexts: string[];
} }
@ -157,7 +177,6 @@ export type LanguageModelInfo = {
// Additional request body data that we send along to the chat API. // Additional request body data that we send along to the chat API.
export const additionalChatRequestParamsSchema = z.object({ export const additionalChatRequestParamsSchema = z.object({
languageModelId: z.string(), languageModelId: z.string(),
selectedRepos: z.array(z.string()), selectedSearchScopes: z.array(searchScopeSchema),
selectedContexts: z.array(z.string()),
}); });
export type AdditionalChatRequestParams = z.infer<typeof additionalChatRequestParamsSchema>; export type AdditionalChatRequestParams = z.infer<typeof additionalChatRequestParamsSchema>;

View file

@ -10,8 +10,7 @@ import { useRouter } from "next/navigation";
import { createChat } from "./actions"; import { createChat } from "./actions";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { createPathWithQueryParams } from "@/lib/utils"; import { createPathWithQueryParams } from "@/lib/utils";
import { SET_CHAT_STATE_QUERY_PARAM, SetChatStatePayload } from "./types"; import { SearchScope, SET_CHAT_STATE_QUERY_PARAM, SetChatStatePayload } from "./types";
import { ContextItem } from "./components/chatBox/contextSelector";
export const useCreateNewChatThread = () => { export const useCreateNewChatThread = () => {
const domain = useDomain(); const domain = useDomain();
@ -19,15 +18,11 @@ export const useCreateNewChatThread = () => {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const createNewChatThread = useCallback(async (children: Descendant[], selectedItems: ContextItem[]) => { const createNewChatThread = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[]) => {
const text = slateContentToString(children); const text = slateContentToString(children);
const mentions = getAllMentionElements(children); const mentions = getAllMentionElements(children);
// Extract repos and contexts from selectedItems const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedSearchScopes);
const selectedRepos = selectedItems.filter(item => item.type === 'repo').map(item => item.value);
const selectedContexts = selectedItems.filter(item => item.type === 'context').map(item => item.value);
const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedRepos, selectedContexts);
setIsLoading(true); setIsLoading(true);
const response = await createChat(domain); const response = await createChat(domain);
@ -42,8 +37,7 @@ export const useCreateNewChatThread = () => {
const url = createPathWithQueryParams(`/${domain}/chat/${response.id}`, const url = createPathWithQueryParams(`/${domain}/chat/${response.id}`,
[SET_CHAT_STATE_QUERY_PARAM, JSON.stringify({ [SET_CHAT_STATE_QUERY_PARAM, JSON.stringify({
inputMessage, inputMessage,
selectedRepos, selectedSearchScopes,
selectedContexts,
} satisfies SetChatStatePayload)], } satisfies SetChatStatePayload)],
); );

View file

@ -12,6 +12,7 @@ import {
SBChatMessage, SBChatMessage,
SBChatMessagePart, SBChatMessagePart,
SBChatMessageToolTypes, SBChatMessageToolTypes,
SearchScope,
Source, Source,
} from "./types" } 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'); return source.split('\n').map((line, index) => `${index + lineOffset}:${line}`).join('\n');
} }
export const createUIMessage = (text: string, mentions: MentionData[], selectedRepos: string[], selectedContexts: string[]): CreateUIMessage<SBChatMessage> => { export const createUIMessage = (text: string, mentions: MentionData[], selectedSearchScopes: SearchScope[]): CreateUIMessage<SBChatMessage> => {
// Converts applicable mentions into sources. // Converts applicable mentions into sources.
const sources: Source[] = mentions const sources: Source[] = mentions
.map((mention) => { .map((mention) => {
@ -205,8 +206,7 @@ export const createUIMessage = (text: string, mentions: MentionData[], selectedR
})) as UIMessagePart<{ source: Source }, SBChatMessageToolTypes>[], })) as UIMessagePart<{ source: Source }, SBChatMessageToolTypes>[],
], ],
metadata: { metadata: {
selectedRepos, selectedSearchScopes,
selectedContexts,
}, },
} }
} }

View file

@ -4,11 +4,11 @@ export const orgMetadataSchema = z.object({
anonymousAccessEnabled: z.boolean().optional(), anonymousAccessEnabled: z.boolean().optional(),
}) })
export const demoSearchContextSchema = z.object({ export const demoSearchScopeSchema = z.object({
id: z.number(), id: z.number(),
displayName: z.string(), displayName: z.string(),
value: z.string(), value: z.string(),
type: z.enum(["repo", "set"]), type: z.enum(["repo", "reposet"]),
codeHostType: z.string().optional(), codeHostType: z.string().optional(),
}) })
@ -16,22 +16,15 @@ export const demoSearchExampleSchema = z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
url: z.string(), url: z.string(),
searchContext: z.array(z.number()) searchScopes: z.array(z.number())
})
export const demoSearchContextExampleSchema = z.object({
searchContext: z.number(),
description: z.string(),
}) })
export const demoExamplesSchema = z.object({ export const demoExamplesSchema = z.object({
searchContexts: demoSearchContextSchema.array(), searchScopes: demoSearchScopeSchema.array(),
searchExamples: demoSearchExampleSchema.array(), searchExamples: demoSearchExampleSchema.array(),
searchContextExamples: demoSearchContextExampleSchema.array(),
}) })
export type OrgMetadata = z.infer<typeof orgMetadataSchema>; export type OrgMetadata = z.infer<typeof orgMetadataSchema>;
export type DemoExamples = z.infer<typeof demoExamplesSchema>; export type DemoExamples = z.infer<typeof demoExamplesSchema>;
export type DemoSearchContext = z.infer<typeof demoSearchContextSchema>; export type DemoSearchScope = z.infer<typeof demoSearchScopeSchema>;
export type DemoSearchExample = z.infer<typeof demoSearchExampleSchema>; export type DemoSearchExample = z.infer<typeof demoSearchExampleSchema>;
export type DemoSearchContextExample = z.infer<typeof demoSearchContextExampleSchema>;