Add demo example cards (#401)

* wip demo example path

* load demo example

* nit: format

* refactor demo cards to their own component

* ui nits

* more ui nits

* feedback
This commit is contained in:
Michael Sukkarieh 2025-07-27 21:26:56 -07:00 committed by GitHub
parent aebd8df193
commit f720ec945d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 345 additions and 235 deletions

View file

@ -12,6 +12,7 @@ export type {
export {
base64Decode,
loadConfig,
loadJsonFile,
isRemotePath,
} from "./utils.js";
export {

View file

@ -3,6 +3,7 @@ import { indexSchema } from "@sourcebot/schemas/v3/index.schema";
import { readFile } from 'fs/promises';
import stripJsonComments from 'strip-json-comments';
import { Ajv } from "ajv";
import { z } from "zod";
const ajv = new Ajv({
validateFormats: false,
@ -18,6 +19,66 @@ export const isRemotePath = (path: string) => {
return path.startsWith('https://') || path.startsWith('http://');
}
// TODO: Merge this with config loading logic which uses AJV
export const loadJsonFile = async <T>(
filePath: string,
schema: any
): Promise<T> => {
const fileContent = await (async () => {
if (isRemotePath(filePath)) {
const response = await fetch(filePath);
if (!response.ok) {
throw new Error(`Failed to fetch file ${filePath}: ${response.statusText}`);
}
return response.text();
} else {
// Retry logic for handling race conditions with mounted volumes
const maxAttempts = 5;
const retryDelayMs = 2000;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await readFile(filePath, {
encoding: 'utf-8',
});
} catch (error) {
lastError = error as Error;
// Only retry on ENOENT errors (file not found)
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
throw error; // Throw immediately for non-ENOENT errors
}
// Log warning before retry (except on the last attempt)
if (attempt < maxAttempts) {
console.warn(`File not found, retrying in 2s... (Attempt ${attempt}/${maxAttempts})`);
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
}
}
}
// If we've exhausted all retries, throw the last ENOENT error
if (lastError) {
throw lastError;
}
throw new Error('Failed to load file after all retry attempts');
}
})();
const parsedData = JSON.parse(stripJsonComments(fileContent));
try {
return schema.parse(parsedData);
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`File '${filePath}' is invalid: ${error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`);
}
throw error;
}
}
export const loadConfig = async (configPath: string): Promise<SourcebotConfig> => {
const configContent = await (async () => {
if (isRemotePath(configPath)) {

View file

@ -1,110 +1,17 @@
'use client';
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ChatBox } from "@/features/chat/components/chatBox";
import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar";
import { LanguageModelInfo } from "@/features/chat/types";
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
import { resetEditor } from "@/features/chat/utils";
import { useDomain } from "@/hooks/useDomain";
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
import { getDisplayTime } from "@/lib/utils";
import { BrainIcon, FileIcon, LucideIcon, SearchIcon } from "lucide-react";
import Link from "next/link";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { ReactEditor, useSlate } from "slate-react";
import { useState } from "react";
import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar";
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
// with the Suggestion system we have built into the chat box.
type SuggestionType = "understand" | "find" | "summarize";
const suggestionTypes: Record<SuggestionType, {
icon: LucideIcon;
title: string;
description: string;
}> = {
understand: {
icon: BrainIcon,
title: "Understand",
description: "Understand the codebase",
},
find: {
icon: SearchIcon,
title: "Find",
description: "Find the codebase",
},
summarize: {
icon: FileIcon,
title: "Summarize",
description: "Summarize the codebase",
},
}
const Highlight = ({ children }: { children: React.ReactNode }) => {
return (
<span className="text-highlight">
{children}
</span>
)
}
const suggestions: Record<SuggestionType, {
queryText: string;
queryNode?: ReactNode;
openRepoSelector?: boolean;
}[]> = {
understand: [
{
queryText: "How does authentication work in this codebase?",
openRepoSelector: true,
},
{
queryText: "How are API endpoints structured and organized?",
openRepoSelector: true,
},
{
queryText: "How does the build and deployment process work?",
openRepoSelector: true,
},
{
queryText: "How is error handling implemented across the application?",
openRepoSelector: true,
},
],
find: [
{
queryText: "Find examples of different logging libraries used throughout the codebase.",
},
{
queryText: "Find examples of potential security vulnerabilities or authentication issues.",
},
{
queryText: "Find examples of API endpoints and route handlers.",
}
],
summarize: [
{
queryText: "Summarize the purpose of this file @file:",
queryNode: <span>Summarize the purpose of this file <Highlight>@file:</Highlight></span>
},
{
queryText: "Summarize the project structure and architecture.",
openRepoSelector: true,
},
{
queryText: "Provide a quick start guide for ramping up on this codebase.",
openRepoSelector: true,
}
],
}
const MAX_RECENT_CHAT_HISTORY_COUNT = 10;
import { DemoExamples } from "@/types";
import { AskSourcebotDemoCards } from "./askSourcebotDemoCards";
interface AgenticSearchProps {
searchModeSelectorProps: SearchModeSelectorProps;
@ -116,6 +23,7 @@ interface AgenticSearchProps {
createdAt: Date;
name: string | null;
}[];
demoExamples: DemoExamples | undefined;
}
export const AgenticSearch = ({
@ -123,42 +31,15 @@ export const AgenticSearch = ({
languageModels,
repos,
searchContexts,
chatHistory,
demoExamples,
}: AgenticSearchProps) => {
const [selectedSuggestionType, _setSelectedSuggestionType] = useState<SuggestionType | undefined>(undefined);
const { createNewChatThread, isLoading } = useCreateNewChatThread();
const dropdownRef = useRef<HTMLDivElement>(null);
const editor = useSlate();
const [selectedItems, setSelectedItems] = useLocalStorage<ContextItem[]>("selectedContextItems", [], { initializeWithValue: false });
const domain = useDomain();
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
const setSelectedSuggestionType = useCallback((type: SuggestionType | undefined) => {
_setSelectedSuggestionType(type);
if (type) {
ReactEditor.focus(editor);
}
}, [editor, _setSelectedSuggestionType]);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
!dropdownRef.current?.contains(event.target as Node)
) {
setSelectedSuggestionType(undefined);
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [setSelectedSuggestionType]);
return (
<div className="flex flex-col items-center w-full max-w-[800px]">
<div
className="mt-4 w-full border rounded-md shadow-sm"
>
<div className="flex flex-col items-center w-full">
<div className="mt-4 w-full border rounded-md shadow-sm max-w-[800px]">
<ChatBox
onSubmit={(children) => {
createNewChatThread(children, selectedItems);
@ -187,111 +68,18 @@ export const AgenticSearch = ({
className="ml-auto"
/>
</div>
</div>
</div>
{selectedSuggestionType && (
<div
ref={dropdownRef}
className="w-full absolute top-10 z-10 drop-shadow-2xl bg-background border rounded-md p-2"
>
<p className="text-muted-foreground text-sm mb-2">
{suggestionTypes[selectedSuggestionType].title}
</p>
{suggestions[selectedSuggestionType].map(({ queryText, queryNode, openRepoSelector }, index) => (
<div
key={index}
className="flex flex-row items-center gap-2 cursor-pointer hover:bg-muted rounded-md px-1 py-0.5"
onClick={() => {
resetEditor(editor);
editor.insertText(queryText);
setSelectedSuggestionType(undefined);
if (openRepoSelector) {
setIsContextSelectorOpen(true);
} else {
ReactEditor.focus(editor);
}
}}
>
<SearchIcon className="w-4 h-4" />
{queryNode ?? queryText}
</div>
))}
</div>
)}
</div>
</div>
<div className="flex flex-col items-center w-fit gap-6 mt-8 relative">
<div className="flex flex-row items-center gap-4">
{Object.entries(suggestionTypes).map(([type, suggestion], index) => (
<ExampleButton
key={index}
Icon={suggestion.icon}
title={suggestion.title}
onClick={() => {
setSelectedSuggestionType(type as SuggestionType);
}}
{demoExamples && (
<AskSourcebotDemoCards
demoExamples={demoExamples}
selectedItems={selectedItems}
setSelectedItems={setSelectedItems}
searchContexts={searchContexts}
repos={repos}
/>
))}
</div>
</div>
{chatHistory.length > 0 && (
<div className="flex flex-col items-center w-[80%]">
<Separator className="my-6" />
<span className="font-semibold mb-2">Recent conversations</span>
<div
className="flex flex-col gap-1 w-full"
>
{chatHistory
.slice(0, MAX_RECENT_CHAT_HISTORY_COUNT)
.map((chat) => (
<Link
key={chat.id}
className="flex flex-row items-center justify-between gap-1 w-full rounded-md hover:bg-muted px-2 py-0.5 cursor-pointer group"
href={`/${domain}/chat/${chat.id}`}
>
<span className="text-sm text-muted-foreground group-hover:text-foreground">
{chat.name ?? "Untitled Chat"}
</span>
<span className="text-sm text-muted-foreground group-hover:text-foreground">
{getDisplayTime(chat.createdAt)}
</span>
</Link>
))}
</div>
{chatHistory.length > MAX_RECENT_CHAT_HISTORY_COUNT && (
<Link
href={`/${domain}/chat`}
className="text-sm text-link hover:underline mt-6"
>
View all
</Link>
)}
</div>
)}
</div >
)
}
interface ExampleButtonProps {
Icon: LucideIcon;
title: string;
onClick: () => void;
}
const ExampleButton = ({
Icon,
title,
onClick,
}: ExampleButtonProps) => {
return (
<Button
variant="secondary"
onClick={onClick}
className="h-9"
>
<Icon className="w-4 h-4" />
{title}
</Button>
)
}

View file

@ -0,0 +1,211 @@
'use client';
import Image from "next/image";
import { Search, LibraryBigIcon, Code, Layers } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { CardContent } from "@/components/ui/card";
import { ContextItem, RepoContextItem, SearchContextItem } from "@/features/chat/components/chatBox/contextSelector";
import { DemoExamples, DemoSearchExample, DemoSearchContextExample, DemoSearchContext } from "@/types";
import { cn, getCodeHostIcon } from "@/lib/utils";
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
interface AskSourcebotDemoCardsProps {
demoExamples: DemoExamples;
selectedItems: ContextItem[];
setSelectedItems: (items: ContextItem[]) => void;
searchContexts: SearchContextQuery[];
repos: RepositoryQuery[];
}
export const AskSourcebotDemoCards = ({
demoExamples,
selectedItems,
setSelectedItems,
searchContexts,
repos,
}: AskSourcebotDemoCardsProps) => {
const handleExampleClick = (example: DemoSearchExample) => {
if (example.url) {
window.open(example.url, '_blank');
}
}
const getContextIcon = (context: DemoSearchContext, size: number = 20) => {
const sizeClass = size === 12 ? "h-3 w-3" : "h-5 w-5";
if (context.type === "set") {
return <LibraryBigIcon className={cn(sizeClass, "text-muted-foreground")} />;
}
if (context.codeHostType) {
const codeHostIcon = getCodeHostIcon(context.codeHostType);
if (codeHostIcon) {
return (
<Image
src={codeHostIcon.src}
alt={`${context.codeHostType} icon`}
width={size}
height={size}
className={cn(sizeClass, codeHostIcon.className)}
/>
);
}
}
return <Code className={cn(sizeClass, "text-muted-foreground")} />;
}
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;
}
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;
}
const isSelected = selectedItems.some(
(selected) => selected.type === 'context' && selected.value === context.value
);
const newSelectedItems = isSelected
? selectedItems.filter(
(selected) => !(selected.type === 'context' && selected.value === context.value)
)
: [...selectedItems, { type: 'context', value: context.value, name: context.displayName, repoCount: searchContext.repoNames.length } as SearchContextItem];
setSelectedItems(newSelectedItems);
} else {
const repo = repos.find((repo) => repo.repoName === context.value);
if (!repo) {
console.error(`Repo ${context.value} not found on handleContextClick`);
return;
}
const isSelected = selectedItems.some(
(selected) => selected.type === 'repo' && selected.value === context.value
);
const newSelectedItems = isSelected
? selectedItems.filter(
(selected) => !(selected.type === 'repo' && selected.value === context.value)
)
: [...selectedItems, { type: 'repo', value: context.value, name: context.displayName, codeHostType: repo.codeHostType } as RepoContextItem];
setSelectedItems(newSelectedItems);
}
}
return (
<div className="w-full mt-8 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 Context</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 */}
<div className="space-y-4">
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-2 mb-2">
<Search className="h-5 w-5 text-muted-foreground" />
<h3 className="text-lg font-semibold">Community Ask Results</h3>
</div>
<p className="text-sm text-muted-foreground">Check out these featured ask results from the community</p>
</div>
<div className="flex flex-wrap justify-center gap-3">
{demoExamples.searchExamples.map((example) => {
const searchContexts = demoExamples.searchContexts.filter((context) => example.searchContext.includes(context.id))
return (
<Card
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]"
onClick={() => handleExampleClick(example)}
>
<CardContent className="p-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
{searchContexts.map((context) => (
<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)}
{context.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>
</CardContent>
</Card>
)})}
</div>
</div>
</div>
);
};

View file

@ -10,6 +10,7 @@ import { SearchMode } from "./toolbar";
import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
import { setSearchModeCookie } from "@/actions";
import { useCallback, useState } from "react";
import { DemoExamples } from "@/types";
interface HomepageProps {
initialRepos: RepositoryQuery[];
@ -21,6 +22,7 @@ interface HomepageProps {
name: string | null;
}[];
initialSearchMode: SearchMode;
demoExamples: DemoExamples | undefined;
}
@ -30,6 +32,7 @@ export const Homepage = ({
languageModels,
chatHistory,
initialSearchMode,
demoExamples,
}: HomepageProps) => {
const [searchMode, setSearchMode] = useState<SearchMode>(initialSearchMode);
const isAgenticSearchEnabled = languageModels.length > 0;
@ -86,6 +89,7 @@ export const Homepage = ({
repos={initialRepos}
searchContexts={searchContexts}
chatHistory={chatHistory}
demoExamples={demoExamples}
/>
</CustomSlateEditor>
)}

View file

@ -11,6 +11,9 @@ import { ServiceErrorException } from "@/lib/serviceError";
import { auth } from "@/auth";
import { cookies } from "next/headers";
import { SEARCH_MODE_COOKIE_NAME } from "@/lib/constants";
import { env } from "@/env.mjs";
import { loadJsonFile } from "@sourcebot/shared";
import { DemoExamples, demoExamplesSchema } from "@/types";
export default async function Home({ params: { domain } }: { params: { domain: string } }) {
const org = await getOrgFromDomain(domain);
@ -48,6 +51,15 @@ export default async function Home({ params: { domain } }: { params: { domain: s
searchModeCookie?.value === "precise"
) ? searchModeCookie.value : models.length > 0 ? "agentic" : "precise";
const demoExamples = env.SOURCEBOT_DEMO_EXAMPLES_PATH ? await (async () => {
try {
return await loadJsonFile<DemoExamples>(env.SOURCEBOT_DEMO_EXAMPLES_PATH!, demoExamplesSchema);
} catch (error) {
console.error('Failed to load demo examples:', error);
return undefined;
}
})() : undefined;
return (
<div className="flex flex-col items-center overflow-hidden min-h-screen">
<NavigationMenu
@ -61,6 +73,7 @@ export default async function Home({ params: { domain } }: { params: { domain: s
languageModels={models}
chatHistory={chatHistory}
initialSearchMode={initialSearchMode}
demoExamples={demoExamples}
/>
<Footer />
</div>

View file

@ -129,6 +129,8 @@ export const env = createEnv({
DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
LANGFUSE_SECRET_KEY: z.string().optional(),
SOURCEBOT_DEMO_EXAMPLES_PATH: z.string().optional(),
},
// @NOTE: Please make sure of the following:
// - Make sure you destructure all client variables in

View file

@ -162,7 +162,7 @@ export const ChatBox = ({
if (isSubmitDisabled) {
if (isSubmitDisabledReason === "no-repos-selected") {
toast({
description: "⚠️ One or more repositories or search contexts must be selected.",
description: "⚠️ You must select at least one search context",
variant: "destructive",
});
onContextSelectorOpenChanged(true);
@ -284,7 +284,7 @@ export const ChatBox = ({
>
<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"
placeholder="Ask, plan, or search your codebase. @mention files to refine your query."
placeholder="Ask questions about the selected search contexts. @mention files to refine your query."
renderElement={renderElement}
renderLeaf={renderLeaf}
onKeyDown={onKeyDown}
@ -339,7 +339,7 @@ export const ChatBox = ({
<TooltipContent>
<div className="flex flex-row items-center">
<TriangleAlertIcon className="h-4 w-4 text-warning mr-1" />
<span className="text-destructive">One or more repositories or search contexts must be selected.</span>
<span className="text-destructive">You must select at least one search context</span>
</div>
</TooltipContent>
)}

View file

@ -194,7 +194,7 @@ export const ContextSelector = React.forwardRef<
>
<Command>
<CommandInput
placeholder="Search contexts and repos..."
placeholder="Search contexts..."
onKeyDown={handleInputKeyDown}
/>
<CommandList ref={scrollContainerRef}>

View file

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