feat(ask_sb): Add search context into ask sourcebot toolbar (#397)

* new context selector

* ui nits

* move search context fetch to server

* feedback

* search context for chat suggestion, nits

* type nit

* fix minor ui nit
This commit is contained in:
Michael Sukkarieh 2025-07-26 16:16:07 -07:00 committed by GitHub
parent e404838960
commit d0f9d43624
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 522 additions and 278 deletions

View file

@ -1862,11 +1862,16 @@ export const getSearchContexts = async (domain: string) => sew(() =>
where: { where: {
orgId: org.id, orgId: org.id,
}, },
include: {
repos: true,
},
}); });
return searchContexts.map((context) => ({ return searchContexts.map((context) => ({
id: context.id,
name: context.name, name: context.name,
description: context.description ?? undefined, description: context.description ?? undefined,
repoNames: context.repos.map((repo) => repo.name),
})); }));
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
)); ));

View file

@ -3,15 +3,17 @@
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, SET_CHAT_STATE_QUERY_PARAM, SetChatStatePayload } from '@/features/chat/types';
import { RepositoryQuery } 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[];
repos: RepositoryQuery[]; repos: RepositoryQuery[];
searchContexts: SearchContextQuery[];
order: number; order: number;
messages: SBChatMessage[]; messages: SBChatMessage[];
isChatReadonly: boolean; isChatReadonly: boolean;
@ -20,6 +22,7 @@ interface ChatThreadPanelProps {
export const ChatThreadPanel = ({ export const ChatThreadPanel = ({
languageModels, languageModels,
repos, repos,
searchContexts,
order, order,
messages, messages,
isChatReadonly, isChatReadonly,
@ -31,8 +34,31 @@ export const ChatThreadPanel = ({
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 we should select by default. // Use the last user's last message to determine what repos and contexts we should select by default.
const [selectedRepos, setSelectedRepos] = useState<string[]>(messages.findLast((message) => message.role === "user")?.metadata?.selectedRepos ?? []); const lastUserMessage = messages.findLast((message) => message.role === "user");
const defaultSelectedRepos = lastUserMessage?.metadata?.selectedRepos ?? [];
const defaultSelectedContexts = lastUserMessage?.metadata?.selectedContexts ?? [];
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);
@ -41,9 +67,28 @@ export const ChatThreadPanel = ({
} }
try { try {
const { inputMessage, selectedRepos } = JSON.parse(setChatState) as SetChatStatePayload; const { inputMessage, selectedRepos, selectedContexts } = JSON.parse(setChatState) as SetChatStatePayload;
setInputMessage(inputMessage); setInputMessage(inputMessage);
setSelectedRepos(selectedRepos); 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 || ''
};
}),
...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');
} }
@ -52,7 +97,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]); }, [searchParams, router, repos, searchContexts]);
return ( return (
<ResizablePanel <ResizablePanel
@ -67,8 +112,9 @@ export const ChatThreadPanel = ({
inputMessage={inputMessage} inputMessage={inputMessage}
languageModels={languageModels} languageModels={languageModels}
repos={repos} repos={repos}
selectedRepos={selectedRepos} searchContexts={searchContexts}
onSelectedReposChange={setSelectedRepos} selectedItems={selectedItems}
onSelectedItemsChange={setSelectedItems}
isChatReadonly={isChatReadonly} isChatReadonly={isChatReadonly}
/> />
</div> </div>

View file

@ -1,4 +1,4 @@
import { getRepos } from '@/actions'; import { getRepos, getSearchContexts } from '@/actions';
import { getUserChatHistory, getConfiguredLanguageModelsInfo, getChatInfo } from '@/features/chat/actions'; import { getUserChatHistory, getConfiguredLanguageModelsInfo, getChatInfo } from '@/features/chat/actions';
import { ServiceErrorException } from '@/lib/serviceError'; import { ServiceErrorException } from '@/lib/serviceError';
import { isServiceError } from '@/lib/utils'; import { isServiceError } from '@/lib/utils';
@ -22,6 +22,7 @@ interface PageProps {
export default async function Page({ params }: PageProps) { export default async function Page({ params }: PageProps) {
const languageModels = await getConfiguredLanguageModelsInfo(); const languageModels = await getConfiguredLanguageModelsInfo();
const repos = await getRepos(params.domain); const repos = await getRepos(params.domain);
const searchContexts = await getSearchContexts(params.domain);
const chatInfo = await getChatInfo({ chatId: params.id }, params.domain); const chatInfo = await getChatInfo({ chatId: params.id }, params.domain);
const session = await auth(); const session = await auth();
const chatHistory = session ? await getUserChatHistory(params.domain) : []; const chatHistory = session ? await getUserChatHistory(params.domain) : [];
@ -34,6 +35,10 @@ export default async function Page({ params }: PageProps) {
throw new ServiceErrorException(repos); throw new ServiceErrorException(repos);
} }
if (isServiceError(searchContexts)) {
throw new ServiceErrorException(searchContexts);
}
if (isServiceError(chatInfo)) { if (isServiceError(chatInfo)) {
if (chatInfo.statusCode === StatusCodes.NOT_FOUND) { if (chatInfo.statusCode === StatusCodes.NOT_FOUND) {
return notFound(); return notFound();
@ -74,6 +79,7 @@ export default async function Page({ params }: PageProps) {
<ChatThreadPanel <ChatThreadPanel
languageModels={languageModels} languageModels={languageModels}
repos={indexedRepos} repos={indexedRepos}
searchContexts={searchContexts}
messages={messages} messages={messages}
order={2} order={2}
isChatReadonly={isReadonly} isChatReadonly={isReadonly}

View file

@ -6,29 +6,32 @@ import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolba
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 } from "@/features/chat/types";
import { RepositoryQuery } 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[];
repos: RepositoryQuery[]; repos: RepositoryQuery[];
searchContexts: SearchContextQuery[];
order: number; order: number;
} }
export const NewChatPanel = ({ export const NewChatPanel = ({
languageModels, languageModels,
repos, repos,
searchContexts,
order, order,
}: NewChatPanelProps) => { }: NewChatPanelProps) => {
const [selectedRepos, setSelectedRepos] = useLocalStorage<string[]>("selectedRepos", [], { initializeWithValue: false }); const [selectedItems, setSelectedItems] = useLocalStorage<ContextItem[]>("selectedContextItems", [], { initializeWithValue: false });
const { createNewChatThread, isLoading } = useCreateNewChatThread(); const { createNewChatThread, isLoading } = useCreateNewChatThread();
const [isRepoSelectorOpen, setIsRepoSelectorOpen] = useState(false); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
const onSubmit = useCallback((children: Descendant[]) => { const onSubmit = useCallback((children: Descendant[]) => {
createNewChatThread(children, selectedRepos); createNewChatThread(children, selectedItems);
}, [createNewChatThread, selectedRepos]); }, [createNewChatThread, selectedItems]);
return ( return (
@ -47,17 +50,19 @@ export const NewChatPanel = ({
preferredSuggestionsBoxPlacement="bottom-start" preferredSuggestionsBoxPlacement="bottom-start"
isRedirecting={isLoading} isRedirecting={isLoading}
languageModels={languageModels} languageModels={languageModels}
selectedRepos={selectedRepos} selectedItems={selectedItems}
onRepoSelectorOpenChanged={setIsRepoSelectorOpen} searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2"> <div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
<ChatBoxToolbar <ChatBoxToolbar
languageModels={languageModels} languageModels={languageModels}
repos={repos} repos={repos}
selectedRepos={selectedRepos} searchContexts={searchContexts}
onSelectedReposChange={setSelectedRepos} selectedItems={selectedItems}
isRepoSelectorOpen={isRepoSelectorOpen} onSelectedItemsChange={setSelectedItems}
onRepoSelectorOpenChanged={setIsRepoSelectorOpen} isContextSelectorOpen={isContextSelectorOpen}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />
</div> </div>
</CustomSlateEditor> </CustomSlateEditor>

View file

@ -1,4 +1,4 @@
import { getRepos } from "@/actions"; import { getRepos, getSearchContexts } from "@/actions";
import { getUserChatHistory, getConfiguredLanguageModelsInfo } from "@/features/chat/actions"; import { getUserChatHistory, getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
import { ServiceErrorException } from "@/lib/serviceError"; import { ServiceErrorException } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
@ -18,6 +18,7 @@ interface PageProps {
export default async function Page({ params }: PageProps) { export default async function Page({ params }: PageProps) {
const languageModels = await getConfiguredLanguageModelsInfo(); const languageModels = await getConfiguredLanguageModelsInfo();
const repos = await getRepos(params.domain); const repos = await getRepos(params.domain);
const searchContexts = await getSearchContexts(params.domain);
const session = await auth(); const session = await auth();
const chatHistory = session ? await getUserChatHistory(params.domain) : []; const chatHistory = session ? await getUserChatHistory(params.domain) : [];
@ -29,6 +30,10 @@ export default async function Page({ params }: PageProps) {
throw new ServiceErrorException(repos); throw new ServiceErrorException(repos);
} }
if (isServiceError(searchContexts)) {
throw new ServiceErrorException(searchContexts);
}
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined); const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
return ( return (
@ -48,6 +53,7 @@ export default async function Page({ params }: PageProps) {
<AnimatedResizableHandle /> <AnimatedResizableHandle />
<NewChatPanel <NewChatPanel
languageModels={languageModels} languageModels={languageModels}
searchContexts={searchContexts}
repos={indexedRepos} repos={indexedRepos}
order={2} order={2}
/> />

View file

@ -8,7 +8,7 @@ import { LanguageModelInfo } from "@/features/chat/types";
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread"; import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
import { resetEditor } from "@/features/chat/utils"; import { resetEditor } from "@/features/chat/utils";
import { useDomain } from "@/hooks/useDomain"; import { useDomain } from "@/hooks/useDomain";
import { RepositoryQuery } from "@/lib/types"; import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
import { getDisplayTime } from "@/lib/utils"; import { getDisplayTime } from "@/lib/utils";
import { BrainIcon, FileIcon, LucideIcon, SearchIcon } from "lucide-react"; import { BrainIcon, FileIcon, LucideIcon, SearchIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@ -16,6 +16,7 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { ReactEditor, useSlate } from "slate-react"; import { ReactEditor, useSlate } from "slate-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";
// @todo: we should probably rename this to a different type since it sort-of clashes // @todo: we should probably rename this to a different type since it sort-of clashes
// with the Suggestion system we have built into the chat box. // with the Suggestion system we have built into the chat box.
@ -109,6 +110,7 @@ interface AgenticSearchProps {
searchModeSelectorProps: SearchModeSelectorProps; searchModeSelectorProps: SearchModeSelectorProps;
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
repos: RepositoryQuery[]; repos: RepositoryQuery[];
searchContexts: SearchContextQuery[];
chatHistory: { chatHistory: {
id: string; id: string;
createdAt: Date; createdAt: Date;
@ -120,15 +122,16 @@ export const AgenticSearch = ({
searchModeSelectorProps, searchModeSelectorProps,
languageModels, languageModels,
repos, repos,
searchContexts,
chatHistory, chatHistory,
}: AgenticSearchProps) => { }: AgenticSearchProps) => {
const [selectedSuggestionType, _setSelectedSuggestionType] = useState<SuggestionType | undefined>(undefined); const [selectedSuggestionType, _setSelectedSuggestionType] = useState<SuggestionType | undefined>(undefined);
const { createNewChatThread, isLoading } = useCreateNewChatThread(); const { createNewChatThread, isLoading } = useCreateNewChatThread();
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const editor = useSlate(); const editor = useSlate();
const [selectedRepos, setSelectedRepos] = useLocalStorage<string[]>("selectedRepos", [], { initializeWithValue: false }); const [selectedItems, setSelectedItems] = useLocalStorage<ContextItem[]>("selectedContextItems", [], { initializeWithValue: false });
const domain = useDomain(); const domain = useDomain();
const [isRepoSelectorOpen, setIsRepoSelectorOpen] = useState(false); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
const setSelectedSuggestionType = useCallback((type: SuggestionType | undefined) => { const setSelectedSuggestionType = useCallback((type: SuggestionType | undefined) => {
_setSelectedSuggestionType(type); _setSelectedSuggestionType(type);
@ -158,13 +161,14 @@ export const AgenticSearch = ({
> >
<ChatBox <ChatBox
onSubmit={(children) => { onSubmit={(children) => {
createNewChatThread(children, selectedRepos); createNewChatThread(children, selectedItems);
}} }}
className="min-h-[50px]" className="min-h-[50px]"
isRedirecting={isLoading} isRedirecting={isLoading}
languageModels={languageModels} languageModels={languageModels}
selectedRepos={selectedRepos} selectedItems={selectedItems}
onRepoSelectorOpenChanged={setIsRepoSelectorOpen} searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />
<Separator /> <Separator />
<div className="relative"> <div className="relative">
@ -172,10 +176,11 @@ export const AgenticSearch = ({
<ChatBoxToolbar <ChatBoxToolbar
languageModels={languageModels} languageModels={languageModels}
repos={repos} repos={repos}
selectedRepos={selectedRepos} searchContexts={searchContexts}
onSelectedReposChange={setSelectedRepos} selectedItems={selectedItems}
isRepoSelectorOpen={isRepoSelectorOpen} onSelectedItemsChange={setSelectedItems}
onRepoSelectorOpenChanged={setIsRepoSelectorOpen} isContextSelectorOpen={isContextSelectorOpen}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />
<SearchModeSelector <SearchModeSelector
{...searchModeSelectorProps} {...searchModeSelectorProps}
@ -201,7 +206,7 @@ export const AgenticSearch = ({
setSelectedSuggestionType(undefined); setSelectedSuggestionType(undefined);
if (openRepoSelector) { if (openRepoSelector) {
setIsRepoSelectorOpen(true); setIsContextSelectorOpen(true);
} else { } else {
ReactEditor.focus(editor); ReactEditor.focus(editor);
} }

View file

@ -2,7 +2,7 @@
import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { LanguageModelInfo } from "@/features/chat/types"; import { LanguageModelInfo } from "@/features/chat/types";
import { RepositoryQuery } from "@/lib/types"; import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
import { useHotkeys } from "react-hotkeys-hook"; import { useHotkeys } from "react-hotkeys-hook";
import { AgenticSearch } from "./agenticSearch"; import { AgenticSearch } from "./agenticSearch";
import { PreciseSearch } from "./preciseSearch"; import { PreciseSearch } from "./preciseSearch";
@ -13,6 +13,7 @@ import { useCallback, useState } from "react";
interface HomepageProps { interface HomepageProps {
initialRepos: RepositoryQuery[]; initialRepos: RepositoryQuery[];
searchContexts: SearchContextQuery[];
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
chatHistory: { chatHistory: {
id: string; id: string;
@ -25,6 +26,7 @@ interface HomepageProps {
export const Homepage = ({ export const Homepage = ({
initialRepos, initialRepos,
searchContexts,
languageModels, languageModels,
chatHistory, chatHistory,
initialSearchMode, initialSearchMode,
@ -82,6 +84,7 @@ export const Homepage = ({
}} }}
languageModels={languageModels} languageModels={languageModels}
repos={initialRepos} repos={initialRepos}
searchContexts={searchContexts}
chatHistory={chatHistory} chatHistory={chatHistory}
/> />
</CustomSlateEditor> </CustomSlateEditor>

View file

@ -1,4 +1,4 @@
import { getRepos } from "@/actions"; import { getRepos, getSearchContexts } from "@/actions";
import { Footer } from "@/app/components/footer"; import { Footer } from "@/app/components/footer";
import { getOrgFromDomain } from "@/data/org"; import { getOrgFromDomain } from "@/data/org";
import { getConfiguredLanguageModelsInfo, getUserChatHistory } from "@/features/chat/actions"; import { getConfiguredLanguageModelsInfo, getUserChatHistory } from "@/features/chat/actions";
@ -22,12 +22,17 @@ export default async function Home({ params: { domain } }: { params: { domain: s
const models = await getConfiguredLanguageModelsInfo(); const models = await getConfiguredLanguageModelsInfo();
const repos = await getRepos(domain); const repos = await getRepos(domain);
const searchContexts = await getSearchContexts(domain);
const chatHistory = session ? await getUserChatHistory(domain) : []; const chatHistory = session ? await getUserChatHistory(domain) : [];
if (isServiceError(repos)) { if (isServiceError(repos)) {
throw new ServiceErrorException(repos); throw new ServiceErrorException(repos);
} }
if (isServiceError(searchContexts)) {
throw new ServiceErrorException(searchContexts);
}
if (isServiceError(chatHistory)) { if (isServiceError(chatHistory)) {
throw new ServiceErrorException(chatHistory); throw new ServiceErrorException(chatHistory);
} }
@ -52,6 +57,7 @@ export default async function Home({ params: { domain } }: { params: { domain: s
<Homepage <Homepage
initialRepos={indexedRepos} initialRepos={indexedRepos}
searchContexts={searchContexts}
languageModels={models} languageModels={models}
chatHistory={chatHistory} chatHistory={chatHistory}
initialSearchMode={initialSearchMode} initialSearchMode={initialSearchMode}

View file

@ -64,11 +64,12 @@ export async function POST(req: Request) {
return serviceErrorResponse(schemaValidationError(parsed.error)); return serviceErrorResponse(schemaValidationError(parsed.error));
} }
const { messages, id, selectedRepos, languageModelId } = parsed.data; const { messages, id, selectedRepos, selectedContexts, languageModelId } = parsed.data;
const response = await chatHandler({ const response = await chatHandler({
messages, messages,
id, id,
selectedRepos, selectedRepos,
selectedContexts,
languageModelId, languageModelId,
}, domain); }, domain);
@ -93,10 +94,11 @@ interface ChatHandlerProps {
messages: SBChatMessage[]; messages: SBChatMessage[];
id: string; id: string;
selectedRepos: string[]; selectedRepos: string[];
selectedContexts?: string[];
languageModelId: string; languageModelId: string;
} }
const chatHandler = ({ messages, id, selectedRepos, languageModelId }: ChatHandlerProps, domain: string) => sew(async () => const chatHandler = ({ messages, id, selectedRepos, selectedContexts, 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({
@ -186,13 +188,34 @@ const chatHandler = ({ messages, id, selectedRepos, languageModelId }: ChatHandl
const startTime = new Date(); const startTime = new Date();
// Expand search contexts to repos
let expandedRepos = [...selectedRepos];
if (selectedContexts && selectedContexts.length > 0) {
const searchContexts = await prisma.searchContext.findMany({
where: {
orgId: org.id,
name: { in: selectedContexts }
},
include: {
repos: true
}
});
const contextRepos = searchContexts.flatMap(context =>
context.repos.map(repo => repo.name)
);
// Combine and deduplicate repos
expandedRepos = Array.from(new Set([...selectedRepos, ...contextRepos]));
}
const researchStream = await createAgentStream({ const researchStream = await createAgentStream({
model, model,
providerOptions, providerOptions,
headers, headers,
inputMessages: messageHistory, inputMessages: messageHistory,
inputSources: sources, inputSources: sources,
selectedRepos, selectedRepos: expandedRepos,
onWriteSource: (source) => { onWriteSource: (source) => {
writer.write({ writer.write({
type: 'data-source', type: 'data-source',

View file

@ -18,6 +18,8 @@ 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";
interface ChatBoxProps { interface ChatBoxProps {
onSubmit: (children: Descendant[], editor: CustomEditor) => void; onSubmit: (children: Descendant[], editor: CustomEditor) => void;
@ -27,8 +29,9 @@ interface ChatBoxProps {
isRedirecting?: boolean; isRedirecting?: boolean;
isGenerating?: boolean; isGenerating?: boolean;
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
selectedRepos: string[]; selectedItems: ContextItem[];
onRepoSelectorOpenChanged: (isOpen: boolean) => void; searchContexts: SearchContextQuery[];
onContextSelectorOpenChanged: (isOpen: boolean) => void;
} }
export const ChatBox = ({ export const ChatBox = ({
@ -39,8 +42,9 @@ export const ChatBox = ({
isRedirecting, isRedirecting,
isGenerating, isGenerating,
languageModels, languageModels,
selectedRepos, selectedItems,
onRepoSelectorOpenChanged, searchContexts,
onContextSelectorOpenChanged,
}: ChatBoxProps) => { }: ChatBoxProps) => {
const suggestionsBoxRef = useRef<HTMLDivElement>(null); const suggestionsBoxRef = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
@ -49,7 +53,20 @@ export const ChatBox = ({
const { suggestions, isLoading } = useSuggestionsData({ const { suggestions, isLoading } = useSuggestionsData({
suggestionMode, suggestionMode,
suggestionQuery, suggestionQuery,
selectedRepos, selectedRepos: selectedItems.map((item) => {
if (item.type === 'repo') {
return [item.value];
}
if (item.type === 'context') {
const context = searchContexts.find((context) => context.name === item.value);
if (context) {
return context.repoNames;
}
}
return [];
}).flat(),
}); });
const { selectedLanguageModel } = useSelectedLanguageModel({ const { selectedLanguageModel } = useSelectedLanguageModel({
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined, initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
@ -113,7 +130,7 @@ export const ChatBox = ({
} }
} }
if (selectedRepos.length === 0) { if (selectedItems.length === 0) {
return { return {
isSubmitDisabled: true, isSubmitDisabled: true,
isSubmitDisabledReason: "no-repos-selected", isSubmitDisabledReason: "no-repos-selected",
@ -137,7 +154,7 @@ export const ChatBox = ({
editor.children, editor.children,
isRedirecting, isRedirecting,
isGenerating, isGenerating,
selectedRepos.length, selectedItems.length,
selectedLanguageModel, selectedLanguageModel,
]) ])
@ -145,17 +162,17 @@ export const ChatBox = ({
if (isSubmitDisabled) { if (isSubmitDisabled) {
if (isSubmitDisabledReason === "no-repos-selected") { if (isSubmitDisabledReason === "no-repos-selected") {
toast({ toast({
description: "⚠️ One or more repositories must be selected.", description: "⚠️ One or more repositories or search contexts must be selected.",
variant: "destructive", variant: "destructive",
}); });
onRepoSelectorOpenChanged(true); onContextSelectorOpenChanged(true);
} }
return; return;
} }
_onSubmit(editor.children, editor); _onSubmit(editor.children, editor);
}, [_onSubmit, editor, isSubmitDisabled, isSubmitDisabledReason, toast, onRepoSelectorOpenChanged]); }, [_onSubmit, editor, isSubmitDisabled, isSubmitDisabledReason, toast, onContextSelectorOpenChanged]);
const onInsertSuggestion = useCallback((suggestion: Suggestion) => { const onInsertSuggestion = useCallback((suggestion: Suggestion) => {
switch (suggestion.type) { switch (suggestion.type) {
@ -322,7 +339,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">One or more repositories must be selected.</span> <span className="text-destructive">One or more repositories or search contexts must be selected.</span>
</div> </div>
</TooltipContent> </TooltipContent>
)} )}

View file

@ -5,34 +5,36 @@ 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 } from "@/features/chat/types";
import { RepositoryQuery } 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 { 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 { RepoSelector } from "./repoSelector"; import { ContextSelector, type ContextItem } from "./contextSelector";
export interface ChatBoxToolbarProps { export interface ChatBoxToolbarProps {
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
repos: RepositoryQuery[]; repos: RepositoryQuery[];
selectedRepos: string[]; searchContexts: SearchContextQuery[];
onSelectedReposChange: (repos: string[]) => void; selectedItems: ContextItem[];
isRepoSelectorOpen: boolean; onSelectedItemsChange: (items: ContextItem[]) => void;
onRepoSelectorOpenChanged: (isOpen: boolean) => void; isContextSelectorOpen: boolean;
onContextSelectorOpenChanged: (isOpen: boolean) => void;
} }
export const ChatBoxToolbar = ({ export const ChatBoxToolbar = ({
languageModels, languageModels,
repos, repos,
selectedRepos, searchContexts,
onSelectedReposChange, selectedItems,
isRepoSelectorOpen, onSelectedItemsChange,
onRepoSelectorOpenChanged, isContextSelectorOpen,
onContextSelectorOpenChanged,
}: ChatBoxToolbarProps) => { }: ChatBoxToolbarProps) => {
const editor = useSlate(); const editor = useSlate();
const onAddContext = useCallback(() => { const onAddContext = useCallback(() => {
editor.insertText("@"); editor.insertText("@");
ReactEditor.focus(editor); ReactEditor.focus(editor);
@ -76,17 +78,18 @@ export const ChatBoxToolbar = ({
<Separator orientation="vertical" className="h-3 mx-1" /> <Separator orientation="vertical" className="h-3 mx-1" />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<RepoSelector <ContextSelector
className="bg-inherit w-fit h-6 min-h-6" className="bg-inherit w-fit h-6 min-h-6"
repos={repos.map((repo) => repo.repoName)} repos={repos}
selectedRepos={selectedRepos} searchContexts={searchContexts}
onSelectedReposChange={onSelectedReposChange} selectedItems={selectedItems}
isOpen={isRepoSelectorOpen} onSelectedItemsChange={onSelectedItemsChange}
onOpenChanged={onRepoSelectorOpenChanged} isOpen={isContextSelectorOpen}
onOpenChanged={onContextSelectorOpenChanged}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">
<span>Repositories to scope conversation to.</span> <span>Search contexts and repositories to scope conversation to.</span>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{languageModels.length > 0 && ( {languageModels.length > 0 && (

View file

@ -0,0 +1,279 @@
// Adapted from: web/src/components/ui/multi-select.tsx
import * as React from "react";
import {
CheckIcon,
ChevronDown,
FolderIcon,
LayersIcon,
LibraryBigIcon,
} from "lucide-react";
import Image from "next/image";
import { cn, getCodeHostIcon } from "@/lib/utils";
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
export type RepoContextItem = {
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[];
searchContexts: SearchContextQuery[];
selectedItems: ContextItem[];
onSelectedItemsChange: (items: ContextItem[]) => void;
className?: string;
isOpen: boolean;
onOpenChanged: (isOpen: boolean) => void;
}
export const ContextSelector = React.forwardRef<
HTMLButtonElement,
ContextSelectorProps
>(
(
{
repos,
searchContexts,
onSelectedItemsChange,
className,
selectedItems,
isOpen,
onOpenChanged,
...props
},
ref
) => {
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const scrollPosition = React.useRef<number>(0);
const handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === "Enter") {
onOpenChanged(true);
} else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedItems = [...selectedItems];
newSelectedItems.pop();
onSelectedItemsChange(newSelectedItems);
}
};
const toggleItem = (item: ContextItem) => {
// Store current scroll position before state update
if (scrollContainerRef.current) {
scrollPosition.current = scrollContainerRef.current.scrollTop;
}
const isSelected = selectedItems.some(
(selected) => selected.type === item.type && selected.value === item.value
);
const newSelectedItems = isSelected
? selectedItems.filter(
(selected) => !(selected.type === item.type && selected.value === item.value)
)
: [...selectedItems, item];
onSelectedItemsChange(newSelectedItems);
};
const handleClear = () => {
onSelectedItemsChange([]);
};
const handleTogglePopover = () => {
onOpenChanged(!isOpen);
};
const allItems = React.useMemo(() => {
const contextItems: ContextItem[] = searchContexts.map(context => ({
type: 'context' as const,
value: context.name,
name: context.name,
repoCount: context.repoNames.length
}));
const repoItems: ContextItem[] = repos.map(repo => ({
type: 'repo' as const,
value: repo.repoName,
name: repo.repoDisplayName || repo.repoName.split('/').pop() || repo.repoName,
codeHostType: repo.codeHostType,
}));
return [...contextItems, ...repoItems];
}, [repos, searchContexts]);
const sortedItems = React.useMemo(() => {
return allItems
.map((item) => ({
item,
isSelected: selectedItems.some(
(selected) => selected.type === item.type && selected.value === item.value
)
}))
.sort((a, b) => {
// Selected items first
if (a.isSelected && !b.isSelected) return -1;
if (!a.isSelected && b.isSelected) return 1;
// Then contexts before repos
if (a.item.type === 'context' && b.item.type === 'repo') return -1;
if (a.item.type === 'repo' && b.item.type === 'context') return 1;
return 0;
})
}, [allItems, selectedItems]);
// Restore scroll position after re-render
React.useEffect(() => {
if (scrollContainerRef.current && scrollPosition.current > 0) {
scrollContainerRef.current.scrollTop = scrollPosition.current;
}
}, [sortedItems]);
return (
<Popover
open={isOpen}
onOpenChange={onOpenChanged}
>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
onClick={handleTogglePopover}
className={cn(
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
className
)}
>
<div className="flex items-center justify-between w-full mx-auto">
<LayersIcon className="h-4 w-4 text-muted-foreground mr-1" />
<span
className={cn("text-sm text-muted-foreground mx-1 font-medium")}
>
{
selectedItems.length === 0 ? `Select context` :
selectedItems.length === 1 ? selectedItems[0].name :
`${selectedItems.length} selected`
}
</span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground ml-2" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
onEscapeKeyDown={() => onOpenChanged(false)}
>
<Command>
<CommandInput
placeholder="Search contexts and repos..."
onKeyDown={handleInputKeyDown}
/>
<CommandList ref={scrollContainerRef}>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{sortedItems.map(({ item, isSelected }) => {
return (
<CommandItem
key={`${item.type}-${item.value}`}
onSelect={() => toggleItem(item)}
className="cursor-pointer"
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
<div className="flex items-center gap-2 flex-1">
{item.type === 'context' ? (
<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 items-center gap-2">
<span className="font-medium">
{item.name}
</span>
{item.type === 'context' && (
<Badge
variant="default"
className="text-[10px] px-1.5 py-0 h-4 bg-primary text-primary-foreground"
>
{item.repoCount} repo{item.repoCount === 1 ? '' : 's'}
</Badge>
)}
</div>
</div>
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
{selectedItems.length > 0 && (
<>
<CommandSeparator />
<CommandItem
onSelect={handleClear}
className="flex-1 justify-center cursor-pointer"
>
Clear
</CommandItem>
</>
)}
</Command>
</PopoverContent>
</Popover>
);
}
);
ContextSelector.displayName = "ContextSelector";

View file

@ -1,191 +0,0 @@
// Adapted from: web/src/components/ui/multi-select.tsx
import * as React from "react";
import {
CheckIcon,
ChevronDown,
BookMarkedIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
interface RepoSelectorProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
repos: string[];
selectedRepos: string[];
onSelectedReposChange: (repos: string[]) => void;
className?: string;
isOpen: boolean;
onOpenChanged: (isOpen: boolean) => void;
}
export const RepoSelector = React.forwardRef<
HTMLButtonElement,
RepoSelectorProps
>(
(
{
repos,
onSelectedReposChange,
className,
selectedRepos,
isOpen,
onOpenChanged,
...props
},
ref
) => {
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const scrollPosition = React.useRef<number>(0);
const handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === "Enter") {
onOpenChanged(true);
} else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedRepos = [...selectedRepos];
newSelectedRepos.pop();
onSelectedReposChange(newSelectedRepos);
}
};
const toggleRepo = (repo: string) => {
// Store current scroll position before state update
if (scrollContainerRef.current) {
scrollPosition.current = scrollContainerRef.current.scrollTop;
}
const newSelectedValues = selectedRepos.includes(repo)
? selectedRepos.filter((value) => value !== repo)
: [...selectedRepos, repo];
onSelectedReposChange(newSelectedValues);
};
const handleClear = () => {
onSelectedReposChange([]);
};
const handleTogglePopover = () => {
onOpenChanged(!isOpen);
};
const sortedRepos = React.useMemo(() => {
return repos
.map((repo) => ({
repo,
isSelected: selectedRepos.includes(repo)
}))
.sort((a, b) => {
if (a.isSelected && !b.isSelected) return -1;
if (!a.isSelected && b.isSelected) return 1;
return 0;
})
}, [repos, selectedRepos]);
// Restore scroll position after re-render
React.useEffect(() => {
if (scrollContainerRef.current && scrollPosition.current > 0) {
scrollContainerRef.current.scrollTop = scrollPosition.current;
}
}, [sortedRepos]);
return (
<Popover
open={isOpen}
onOpenChange={onOpenChanged}
>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
onClick={handleTogglePopover}
className={cn(
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
className
)}
>
<div className="flex items-center justify-between w-full mx-auto">
<BookMarkedIcon className="h-4 w-4 text-muted-foreground mr-1" />
<span
className={cn("text-sm text-muted-foreground mx-1 font-medium")}
>
{
selectedRepos.length === 0 ? `Select a repo` :
selectedRepos.length === 1 ? `${selectedRepos[0].split('/').pop()}` :
`${selectedRepos.length} repo${selectedRepos.length === 1 ? '' : 's'}`
}
</span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground ml-2" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
onEscapeKeyDown={() => onOpenChanged(false)}
>
<Command>
<CommandInput
placeholder="Search repos..."
onKeyDown={handleInputKeyDown}
/>
<CommandList ref={scrollContainerRef}>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{sortedRepos.map(({ repo, isSelected }) => {
return (
<CommandItem
key={repo}
onSelect={() => toggleRepo(repo)}
className="cursor-pointer"
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
<span>{repo}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
{selectedRepos.length > 0 && (
<>
<CommandSeparator />
<CommandItem
onSelect={handleClear}
className="flex-1 justify-center cursor-pointer"
>
Clear
</CommandItem>
</>
)}
</Command>
</PopoverContent>
</Popover>
);
}
);
RepoSelector.displayName = "RepoSelector";

View file

@ -12,7 +12,7 @@ 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, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, 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';
@ -22,7 +22,8 @@ import { ChatThreadListItem } from './chatThreadListItem';
import { ErrorBanner } from './errorBanner'; 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 } from '@/lib/types'; import { RepositoryQuery, SearchContextQuery } from '@/lib/types';
import { ContextItem } from '../chatBox/contextSelector';
type ChatHistoryState = { type ChatHistoryState = {
scrollOffset?: number; scrollOffset?: number;
@ -34,8 +35,9 @@ interface ChatThreadProps {
inputMessage?: CreateUIMessage<SBChatMessage>; inputMessage?: CreateUIMessage<SBChatMessage>;
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
repos: RepositoryQuery[]; repos: RepositoryQuery[];
selectedRepos: string[]; searchContexts: SearchContextQuery[];
onSelectedReposChange: (repos: string[]) => void; selectedItems: ContextItem[];
onSelectedItemsChange: (items: ContextItem[]) => void;
isChatReadonly: boolean; isChatReadonly: boolean;
} }
@ -45,8 +47,9 @@ export const ChatThread = ({
inputMessage, inputMessage,
languageModels, languageModels,
repos, repos,
selectedRepos, searchContexts,
onSelectedReposChange, selectedItems,
onSelectedItemsChange,
isChatReadonly, isChatReadonly,
}: ChatThreadProps) => { }: ChatThreadProps) => {
const domain = useDomain(); const domain = useDomain();
@ -57,7 +60,13 @@ export const ChatThread = ({
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(false); const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const [isRepoSelectorOpen, setIsRepoSelectorOpen] = 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[]>(
@ -114,10 +123,11 @@ export const ChatThread = ({
_sendMessage(message, { _sendMessage(message, {
body: { body: {
selectedRepos, selectedRepos,
selectedContexts,
languageModelId: selectedLanguageModel.model, languageModelId: selectedLanguageModel.model,
} satisfies AdditionalChatRequestParams, } satisfies AdditionalChatRequestParams,
}); });
}, [_sendMessage, selectedLanguageModel, selectedRepos, toast]); }, [_sendMessage, selectedLanguageModel, toast, selectedRepos, selectedContexts]);
const messagePairs = useMessagePairs(messages); const messagePairs = useMessagePairs(messages);
@ -233,13 +243,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); const message = createUIMessage(text, mentions.map(({ data }) => data), selectedRepos, selectedContexts);
sendMessage(message); sendMessage(message);
setIsAutoScrollEnabled(true); setIsAutoScrollEnabled(true);
resetEditor(editor); resetEditor(editor);
}, [sendMessage, selectedRepos]); }, [sendMessage, selectedRepos, selectedContexts]);
return ( return (
<> <>
@ -317,17 +327,19 @@ export const ChatThread = ({
isGenerating={status === "streaming" || status === "submitted"} isGenerating={status === "streaming" || status === "submitted"}
onStop={stop} onStop={stop}
languageModels={languageModels} languageModels={languageModels}
selectedRepos={selectedRepos} selectedItems={selectedItems}
onRepoSelectorOpenChanged={setIsRepoSelectorOpen} searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2"> <div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
<ChatBoxToolbar <ChatBoxToolbar
languageModels={languageModels} languageModels={languageModels}
repos={repos} repos={repos}
selectedRepos={selectedRepos} searchContexts={searchContexts}
onSelectedReposChange={onSelectedReposChange} selectedItems={selectedItems}
isRepoSelectorOpen={isRepoSelectorOpen} onSelectedItemsChange={onSelectedItemsChange}
onRepoSelectorOpenChanged={setIsRepoSelectorOpen} isContextSelectorOpen={isContextSelectorOpen}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />
</div> </div>
</CustomSlateEditor> </CustomSlateEditor>

View file

@ -51,6 +51,7 @@ export const sbChatMessageMetadataSchema = z.object({
userId: z.string(), userId: z.string(),
})).optional(), })).optional(),
selectedRepos: z.array(z.string()).optional(), selectedRepos: z.array(z.string()).optional(),
selectedContexts: z.array(z.string()).optional(),
traceId: z.string().optional(), traceId: z.string().optional(),
}); });
@ -139,6 +140,7 @@ export const SET_CHAT_STATE_QUERY_PARAM = 'setChatState';
export type SetChatStatePayload = { export type SetChatStatePayload = {
inputMessage: CreateUIMessage<SBChatMessage>; inputMessage: CreateUIMessage<SBChatMessage>;
selectedRepos: string[]; selectedRepos: string[];
selectedContexts: string[];
} }
@ -156,5 +158,6 @@ export type LanguageModelInfo = {
export const additionalChatRequestParamsSchema = z.object({ export const additionalChatRequestParamsSchema = z.object({
languageModelId: z.string(), languageModelId: z.string(),
selectedRepos: z.array(z.string()), selectedRepos: z.array(z.string()),
selectedContexts: z.array(z.string()),
}); });
export type AdditionalChatRequestParams = z.infer<typeof additionalChatRequestParamsSchema>; export type AdditionalChatRequestParams = z.infer<typeof additionalChatRequestParamsSchema>;

View file

@ -11,6 +11,7 @@ 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 { 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();
@ -18,10 +19,15 @@ export const useCreateNewChatThread = () => {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const createNewChatThread = useCallback(async (children: Descendant[], selectedRepos: string[]) => { const createNewChatThread = useCallback(async (children: Descendant[], selectedItems: ContextItem[]) => {
const text = slateContentToString(children); const text = slateContentToString(children);
const mentions = getAllMentionElements(children); const mentions = getAllMentionElements(children);
const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedRepos);
// Extract repos and contexts from selectedItems
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);
@ -37,6 +43,7 @@ export const useCreateNewChatThread = () => {
[SET_CHAT_STATE_QUERY_PARAM, JSON.stringify({ [SET_CHAT_STATE_QUERY_PARAM, JSON.stringify({
inputMessage, inputMessage,
selectedRepos, selectedRepos,
selectedContexts,
} satisfies SetChatStatePayload)], } satisfies SetChatStatePayload)],
); );

View file

@ -172,7 +172,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[]): CreateUIMessage<SBChatMessage> => { export const createUIMessage = (text: string, mentions: MentionData[], selectedRepos: string[], selectedContexts: string[]): CreateUIMessage<SBChatMessage> => {
// Converts applicable mentions into sources. // Converts applicable mentions into sources.
const sources: Source[] = mentions const sources: Source[] = mentions
.map((mention) => { .map((mention) => {
@ -206,6 +206,7 @@ export const createUIMessage = (text: string, mentions: MentionData[], selectedR
], ],
metadata: { metadata: {
selectedRepos, selectedRepos,
selectedContexts,
}, },
} }
} }

View file

@ -28,6 +28,13 @@ export const repositoryQuerySchema = z.object({
repoIndexingStatus: z.nativeEnum(RepoIndexingStatus), repoIndexingStatus: z.nativeEnum(RepoIndexingStatus),
}); });
export const searchContextQuerySchema = z.object({
id: z.number(),
name: z.string(),
description: z.string().optional(),
repoNames: z.array(z.string()),
});
export const verifyCredentialsRequestSchema = z.object({ export const verifyCredentialsRequestSchema = z.object({
email: z.string().email(), email: z.string().email(),
password: z.string().min(8), password: z.string().min(8),

View file

@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { getVersionResponseSchema, repositoryQuerySchema } from "./schemas"; import { getVersionResponseSchema, repositoryQuerySchema, searchContextQuerySchema } from "./schemas";
import { tenancyModeSchema } from "@/env.mjs"; import { tenancyModeSchema } from "@/env.mjs";
export type KeymapType = "default" | "vim"; export type KeymapType = "default" | "vim";
@ -25,4 +25,5 @@ export type NewsItem = {
} }
export type TenancyMode = z.infer<typeof tenancyModeSchema>; export type TenancyMode = z.infer<typeof tenancyModeSchema>;
export type RepositoryQuery = z.infer<typeof repositoryQuerySchema>; export type RepositoryQuery = z.infer<typeof repositoryQuerySchema>;
export type SearchContextQuery = z.infer<typeof searchContextQuerySchema>;