@ragrabbit/mcp
by madarco
Verified
- packages
- design
- components
- chat
- assistant-ui
"use client";
import {
ActionBarPrimitive,
BranchPickerPrimitive,
ComposerPrimitive,
MessagePrimitive,
ThreadPrimitive,
useAssistantRuntime,
useMessage,
useThread,
} from "@assistant-ui/react";
import type { FC } from "react";
import { useState, useCallback } from "react";
import React from "react";
import { cn } from "@repo/design/lib/utils";
import { Avatar, AvatarFallback } from "@repo/design/shadcn/avatar";
import { Button } from "@repo/design/shadcn/button";
import {
ArrowDownIcon,
CheckIcon,
ChevronLeftIcon,
CopyIcon,
SendHorizontalIcon,
ChevronRightIcon,
RefreshCwIcon,
PencilIcon,
} from "lucide-react";
import Image from "next/image";
import { useChatConfig } from "../chat-config-provider";
import { MarkdownText } from "./markdown-text";
import { TooltipIconButton } from "./tooltip-icon-button";
import { useChatProvider } from "../chat-provider";
import { SourceBoxListStory } from "../source-box.stories";
import { Source, SourceBoxList } from "../source-box";
import { Skeleton } from "@repo/design/shadcn/skeleton";
import { SearchResults } from "../search-results";
import { useDebounce } from "use-debounce";
export const MyThread: FC = () => {
const { threads } = useAssistantRuntime();
const { modalMode } = useChatConfig();
const [hideWelcome, setHideWelcome] = useState(false);
const doHideWelcome = modalMode && hideWelcome;
return (
<ThreadPrimitive.Root
className={cn(
"bg-background h-full w-full min-h-[0] transition-all duration-700 ease-in-out",
doHideWelcome && "min-h-[100dvh]",
modalMode && "pt-4 pb-8"
)}
>
<ThreadPrimitive.Viewport className="flex h-full flex-col items-center overflow-y-scroll scroll-smooth bg-inherit px-4">
<div className={cn(doHideWelcome && "invisible", !modalMode && "pt-8")}>
<MyThreadWelcome hideWelcome={doHideWelcome} />
</div>
<ThreadPrimitive.Messages
components={{
UserMessage: MyUserMessage,
EditComposer: MyEditComposer,
AssistantMessage: MyAssistantMessage,
}}
/>
<div className="min-h-8 flex-grow" />
<div className="sticky bottom-0 mt-3 flex w-full max-w-4xl flex-col items-center justify-end rounded-t-lg bg-inherit pb-4">
<MyThreadScrollToBottom />
<ThreadPrimitive.If empty={false}>
<ThreadPrimitive.If running={false}>
<Button variant="outline" className="mb-4" onClick={() => threads.switchToNewThread()}>
Start new chat
</Button>
</ThreadPrimitive.If>
</ThreadPrimitive.If>
<MyComposer setActive={setHideWelcome} />
<MySuggestedPromptsInitial />
<MySuggestedPrompts />
</div>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
);
};
const MySuggestedPromptsInitial: FC = () => {
const chatConfig = useChatConfig();
return (
<ThreadPrimitive.If empty={true}>
{chatConfig.suggestedQueries && (
<div className="mt-4 text-center">
{chatConfig.suggestedQueries.map((query) => (
<ThreadPrimitive.Suggestion prompt={query} method="replace" autoSend asChild key={query}>
<Button variant="outline" className="flex-1 py-1 md:py-2 mr-2 mb-2">
{query}
</Button>
</ThreadPrimitive.Suggestion>
))}
</div>
)}
</ThreadPrimitive.If>
);
};
const MySuggestedPrompts: FC = () => {
const lastMessage = useThread((t) => t.messages.at(-1));
const annotation: any = lastMessage?.metadata?.unstable_annotations?.find((a: any) => a.type === "suggested-prompts");
const suggestions = annotation?.data;
if (!suggestions) return null;
return (
<div className="mt-4 text-center">
{suggestions.map((query) => (
<ThreadPrimitive.Suggestion prompt={query} method="replace" autoSend asChild key={query}>
<Button variant="outline" className="flex-1 py-1 md:py-2 mr-2 mb-2">
{query}
</Button>
</ThreadPrimitive.Suggestion>
))}
</div>
);
};
const MyThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="absolute -top-8 rounded-full disabled:invisible"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};
const MyThreadWelcome: FC<{ hideWelcome: boolean }> = ({ hideWelcome }) => {
const chatConfig = useChatConfig();
return (
<div
className={cn(
"flex flex-col items-center justify-center transition-all duration-300 ease-in-out",
hideWelcome ? "max-h-0 mb-0 opacity-0 scale-95 pointer-events-none" : "max-h-[200px] mb-8 opacity-100 scale-100"
)}
>
<div className="flex flex-col items-center">
{chatConfig.logoUrl && <Image src={chatConfig.logoUrl} alt="Logo" width={40} height={40} />}
{!chatConfig.logoUrl && (
<Avatar>
<AvatarFallback>C</AvatarFallback>
</Avatar>
)}
<p className="mt-4 font-medium">{chatConfig.welcomeMessage || "What do you want to know?"}</p>
</div>
</div>
);
};
interface MyComposerProps {
setActive: (focus: boolean) => void;
}
export const MyComposer: FC<MyComposerProps> = ({ setActive }) => {
const [searchResults, setSearchResults] = useState<any[]>([]);
// NB: searchValue is used only for the search results, the AI chat stores internally in ComposerPrimitive.Input
const [searchValue, setSearchValue] = useState("");
const [debouncedValue] = useDebounce(searchValue, 200);
const handleSearch = useCallback(async (query: string) => {
if (query.length < 3) {
setSearchResults([]);
return;
}
try {
const response = await fetch("/chat/ui/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
const data = await response.json();
setSearchResults(data.results || []);
} catch (error) {
console.error("Search error:", error);
setSearchResults([]);
}
}, []);
// Effect to trigger search when debounced value changes
React.useEffect(() => {
handleSearch(debouncedValue);
}, [debouncedValue, handleSearch]);
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setSearchValue(e.target.value);
setActive(true);
};
const handleSubmit = () => {
setSearchResults([]);
setSearchValue("");
};
return (
<>
<ComposerPrimitive.Root className="focus-within:border-aui-ring/20 flex w-full flex-wrap items-end rounded-lg border bg-inherit px-2.5 shadow-sm transition-colors ease-in">
<ComposerPrimitive.Input
autoFocus
placeholder="Ask a question or search"
rows={1}
onChange={handleInputChange}
onClick={() => setActive(true)}
className="placeholder:text-muted-foreground max-h-40 flex-grow resize-none border-none bg-transparent px-2 py-4 text-sm outline-none focus:ring-0 disabled:cursor-not-allowed"
/>
<ThreadPrimitive.If running={false}>
<ComposerPrimitive.Send asChild onClick={handleSubmit}>
<TooltipIconButton
tooltip="Send"
variant="default"
className="my-2.5 size-8 p-2 transition-opacity ease-in"
>
<SendHorizontalIcon />
</TooltipIconButton>
</ComposerPrimitive.Send>
</ThreadPrimitive.If>
<ThreadPrimitive.If running>
<ComposerPrimitive.Cancel asChild>
<TooltipIconButton
tooltip="Cancel"
variant="default"
className="my-2.5 size-8 p-2 transition-opacity ease-in"
>
<CircleStopIcon />
</TooltipIconButton>
</ComposerPrimitive.Cancel>
</ThreadPrimitive.If>
</ComposerPrimitive.Root>
<ThreadPrimitive.If empty>
{searchValue && searchResults.length > 0 && (
<>
<div className="w-full text-sm text-muted-foreground mt-2 ml-6 flex items-center">
Submit to ask AI
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M15.5 9.00001V15H8.5M8.5 15L9.5 14M8.5 15L9.5 16M13 5H17.5C18.0523 5 18.5 5.44772 18.5 6V18C18.5 18.5523 18.0523 19 17.5 19H6.5C5.94772 19 5.5 18.5523 5.5 18V12C5.5 11.4477 5.94772 11 6.5 11H12V6C12 5.44771 12.4477 5 13 5Z"
stroke="#464455"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</>
)}
<SearchResults results={searchResults} />
</ThreadPrimitive.If>
</>
);
};
const MyUserMessage: FC = () => {
return (
<MessagePrimitive.Root className="grid w-full max-w-4xl auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] gap-y-2 py-4">
<MyUserActionBar />
<div className="bg-muted text-foreground col-start-2 row-start-1 break-words rounded-3xl px-5 py-2.5">
<MessagePrimitive.Content />
</div>
<MyBranchPicker className="col-span-full col-start-1 row-start-2 -mr-1 justify-end" />
</MessagePrimitive.Root>
);
};
const MyUserActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="col-start-1 mr-3 mt-2.5 flex flex-col items-end"
>
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit">
<PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
);
};
const MyEditComposer: FC = () => {
return (
<ComposerPrimitive.Root className="bg-muted my-4 flex w-full max-w-2xl flex-col gap-2 rounded-xl">
<ComposerPrimitive.Input className="text-foreground flex h-8 w-full resize-none border-none bg-transparent p-4 pb-0 outline-none focus:ring-0" />
<div className="mx-3 mb-3 flex items-center justify-center gap-2 self-end">
<ComposerPrimitive.Cancel asChild>
<Button variant="ghost">Cancel</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<Button>Send</Button>
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
);
};
const MyAssistantMessage: FC = () => {
const message = useMessage();
const annotation: any = message?.metadata?.unstable_annotations?.find((a: any) => a.type === "sources");
const sources = annotation?.data;
// Show skeleton loading state when message is empty
if (!message?.content?.length && !annotation) {
return (
<div className="relative grid w-full max-w-4xl grid-cols-[auto_auto_1fr] grid-rows-[auto_1fr] py-4">
<Avatar className="col-start-1 row-span-full row-start-1 mr-4">
<AvatarFallback>AI</AvatarFallback>
</Avatar>
<div className="col-span-2 col-start-2 space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
);
}
return (
<div className="relative grid w-full max-w-4xl grid-cols-[auto_auto_1fr] grid-rows-[auto_1fr] py-4">
<Avatar className="col-start-1 row-span-full row-start-1 mr-4">
<AvatarFallback>AI</AvatarFallback>
</Avatar>
<div className="text-foreground col-span-2 col-start-2 row-start-1 my-1.5 break-words leading-7">
{annotation && <SourceBoxList sources={sources} />}
</div>
<MessagePrimitive.Root>
<div className="text-foreground col-span-2 col-start-2 row-start-1 my-1.5 break-words leading-7">
<MessagePrimitive.Content components={{ Text: MarkdownText }} />
</div>
<MyAssistantActionBar />
<MyBranchPicker className="col-start-2 row-start-2 -ml-2 mr-2" />
</MessagePrimitive.Root>
</div>
);
};
const MyAssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
autohideFloat="single-branch"
className="text-muted-foreground data-[floating]:bg-background col-start-3 row-start-2 -ml-1 flex gap-1 data-[floating]:absolute data-[floating]:rounded-md data-[floating]:border data-[floating]:p-1 data-[floating]:shadow-sm"
>
{/* <MessagePrimitive.If speaking={false}>
<ActionBarPrimitive.Speak asChild>
<TooltipIconButton tooltip="Read aloud">
<AudioLinesIcon />
</TooltipIconButton>
</ActionBarPrimitive.Speak>
</MessagePrimitive.If>
<MessagePrimitive.If speaking>
<ActionBarPrimitive.StopSpeaking asChild>
<TooltipIconButton tooltip="Stop">
<StopCircleIcon />
</TooltipIconButton>
</ActionBarPrimitive.StopSpeaking>
</MessagePrimitive.If>*/}
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<MessagePrimitive.If copied>
<CheckIcon />
</MessagePrimitive.If>
<MessagePrimitive.If copied={false}>
<CopyIcon />
</MessagePrimitive.If>
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Refresh">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
</ActionBarPrimitive.Root>
);
};
const MyBranchPicker: FC<any> = ({ className, ...rest }) => {
return (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn("text-muted-foreground inline-flex items-center text-xs", className)}
{...rest}
>
<BranchPickerPrimitive.Previous asChild>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Previous>
<span className="font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
};
const CircleStopIcon = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" width="16" height="16">
<rect width="10" height="10" x="3" y="3" rx="2" />
</svg>
);
};