Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 4 additions & 17 deletions apps/portal/src/app/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
"use client";

import clsx from "clsx";
import {
ChevronDownIcon,
MenuIcon,
MessageCircleIcon,
TableOfContentsIcon,
} from "lucide-react";
import { ChevronDownIcon, MenuIcon, TableOfContentsIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
Expand All @@ -18,6 +13,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChatButton } from "../components/AI/chat-button";
import { GithubIcon } from "../components/Document/GithubButtonLink";
import { CustomAccordion } from "../components/others/CustomAccordion";
import { ThemeSwitcher } from "../components/others/theme/ThemeSwitcher";
Expand Down Expand Up @@ -242,22 +238,13 @@ export function Header() {
</div>

<div className="hidden xl:block">
<Button asChild>
<Link href="/chat">
<MessageCircleIcon className="mr-2 size-4" />
Ask AI
</Link>
</Button>
<ChatButton />
</div>

<div className="flex items-center gap-1 xl:hidden">
<ThemeSwitcher className="border-none bg-transparent" />
<DocSearch variant="icon" />
<Button className="p-2" asChild variant="ghost">
<Link href="/chat">
<MessageCircleIcon className="size-6" />
</Link>
</Button>
<ChatButton />
<Button
className="p-2"
onClick={() => setShowBurgerMenu(!showBurgerMenu)}
Expand Down
16 changes: 0 additions & 16 deletions apps/portal/src/app/chat/page.tsx

This file was deleted.

12 changes: 4 additions & 8 deletions apps/portal/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { BotIcon, MessageCircleIcon, WebhookIcon, ZapIcon } from "lucide-react";
import { BotIcon, WebhookIcon, ZapIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Heading } from "@/components/Document";
import { Button } from "@/components/ui/button";
import { ChatButton } from "../components/AI/chat-button";
import {
DotNetIcon,
ExternalLinkIcon,
Expand All @@ -18,6 +18,7 @@ import { InsightIcon } from "../icons/products/InsightIcon";
import { PlaygroundIcon } from "../icons/products/PlaygroundIcon";
import DocsHeroDark from "./_images/docs-hero-dark.png";
import DocsHeroLight from "./_images/docs-hero-light.png";

export default function Page() {
return (
<main className="container max-w-5xl grow pb-20" data-noindex>
Expand All @@ -44,12 +45,7 @@ function Hero() {
Development framework for building onchain apps, games, and agents.
</p>
<div className="flex">
<Button className="flex items-center gap-2" asChild>
<Link href="/chat">
<MessageCircleIcon className="size-4" />
Ask AI
</Link>
</Button>
<ChatButton />
</div>
</div>
</div>
Expand Down
88 changes: 88 additions & 0 deletions apps/portal/src/components/AI/chat-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import { MessageCircleIcon, RefreshCcwIcon, XIcon } from "lucide-react";
import { lazy, Suspense, useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Spinner } from "../ui/Spinner/Spinner";

const Chat = lazy(() =>
import("./chat").then((mod) => ({ default: mod.Chat })),
);

export function ChatButton() {
const [isOpen, setIsOpen] = useState(false);
const [hasBeenOpened, setHasBeenOpened] = useState(false);
const closeModal = useCallback(() => setIsOpen(false), []);
const [id, setId] = useState(0);

return (
<>
{/* Inline Button (not floating) */}
<Button
className="gap-2 rounded-full shadow-lg"
onClick={() => {
setIsOpen(true);
setHasBeenOpened(true);
}}
variant="default"
>
<MessageCircleIcon className="size-4" />
Ask AI
</Button>

{/* Popup/Modal */}
<div
className={cn(
"slide-in-from-bottom-20 zoom-in-95 fade-in-0 fixed bottom-0 left-0 z-modal flex h-[80vh] w-[100vw] animate-in flex-col overflow-hidden rounded-t-2xl border bg-background shadow-2xl duration-200 lg:right-6 lg:bottom-6 lg:left-auto lg:h-[80vh] lg:max-w-xl lg:rounded-xl",
!isOpen && "hidden",
)}
>
{/* Header with close button */}
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-2 font-medium text-lg pl-0.5">
Ask AI
</div>

<div className="flex items-center gap-2">
<Button
className="size-auto p-1 text-muted-foreground rounded-full"
onClick={() => setId((x) => x + 1)}
size="icon"
aria-label="Reset chat"
variant="ghost"
>
<RefreshCcwIcon className="size-5" />
</Button>

<Button
aria-label="Close chat"
className="size-auto p-1 text-muted-foreground rounded-full"
onClick={closeModal}
size="icon"
variant="ghost"
>
<XIcon className="size-5" />
</Button>
</div>
</div>
{/* Chat Content */}
<div className="flex grow flex-col overflow-hidden relative">
{hasBeenOpened && (
<Suspense fallback={<ChatLoading />}>
<Chat key={id} />
</Suspense>
)}
</div>
</div>
</>
);
}

function ChatLoading() {
return (
<div className="flex items-center justify-center p-8 absolute inset-0">
<Spinner className="size-10" />
</div>
);
}
123 changes: 68 additions & 55 deletions apps/portal/src/components/AI/chat.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"use client";

import { useMutation } from "@tanstack/react-query";
import {
QueryClient,
QueryClientProvider,
useMutation,
} from "@tanstack/react-query";
import {
ArrowUpIcon,
ThumbsDownIcon,
Expand Down Expand Up @@ -41,6 +45,8 @@ const predefinedPrompts = [
"How do I send a transaction in Unity?",
];

const queryClient = new QueryClient();

// Empty State Component
function ChatEmptyState({
onPromptClick,
Expand Down Expand Up @@ -156,13 +162,16 @@ export function Chat() {
[conversationId, posthog],
);

const lastMessageLength = messages[messages.length - 1]?.content.length ?? 0;

// biome-ignore lint/correctness/useExhaustiveDependencies: need both the number of messages and the last message length to trigger the scroll
useEffect(() => {
if (scrollAnchorRef.current && messages.length > 0) {
scrollAnchorRef.current.scrollIntoView({
behavior: "smooth",
});
}
}, [messages.length]);
}, [messages.length, lastMessageLength]);

const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
Expand All @@ -178,57 +187,59 @@ export function Chat() {
};

return (
<div className="container flex max-h-full flex-col grow overflow-hidden lg:max-w-4xl pb-6">
<Toaster richColors />
<div className="relative flex max-h-full flex-1 flex-col overflow-hidden">
{messages.length === 0 ? (
<ChatEmptyState onPromptClick={handleSendMessage} />
) : (
<ScrollShadow
className="flex-1"
scrollableClassName="max-h-full overscroll-contain"
shadowColor="hsl(var(--background))"
shadowClassName="z-[1]"
>
<div className="space-y-8 pt-10 pb-16">
{messages.map((message) => (
<RenderMessage
conversationId={conversationId}
message={message}
key={message.id}
/>
))}
</div>
<div ref={scrollAnchorRef} />
</ScrollShadow>
)}
</div>
<QueryClientProvider client={queryClient}>
<div className="flex max-h-full flex-col grow overflow-hidden">
<Toaster richColors />
<div className="relative flex max-h-full flex-1 flex-col overflow-hidden px-4">
{messages.length === 0 ? (
<ChatEmptyState onPromptClick={handleSendMessage} />
) : (
<ScrollShadow
className="flex-1"
scrollableClassName="max-h-full overscroll-contain"
shadowColor="hsl(var(--background))"
shadowClassName="z-[1]"
>
<div className="space-y-8 pt-6 pb-16">
{messages.map((message) => (
<RenderMessage
conversationId={conversationId}
message={message}
key={message.id}
/>
))}
</div>
<div ref={scrollAnchorRef} />
</ScrollShadow>
)}
</div>

<div className="relative z-stickyTop">
<AutoResizeTextarea
className="min-h-[120px] rounded-xl bg-card"
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Ask AI Assistant..."
rows={2}
value={input}
/>
<Button
className="absolute bottom-3 right-3 disabled:opacity-100 !h-auto w-auto shrink-0 gap-2 p-2"
disabled={!input.trim()}
onClick={() => {
const currentInput = input;
setInput("");
handleSendMessage(currentInput);
}}
type="submit"
size="sm"
variant="default"
>
<ArrowUpIcon className="size-4" />
</Button>
<div className="relative z-stickyTop">
<AutoResizeTextarea
className="min-h-[120px] rounded-xl border-x-0 border-b-0 rounded-t-none bg-card focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Ask AI Assistant..."
rows={2}
value={input}
/>
<Button
className="absolute bottom-3 right-3 disabled:opacity-100 !h-auto w-auto shrink-0 gap-2 p-2"
disabled={!input.trim()}
onClick={() => {
const currentInput = input;
setInput("");
handleSendMessage(currentInput);
}}
type="submit"
size="sm"
variant="default"
>
<ArrowUpIcon className="size-4" />
</Button>
</div>
</div>
</div>
</QueryClientProvider>
);
}

Expand Down Expand Up @@ -272,7 +283,7 @@ function RenderAIResponse(props: {
return (
<div className="flex items-start gap-3.5">
{aiIcon}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex-1 min-w-0 overflow-hidden fade-in-0 duration-300 animate-in">
<StyledMarkdownRenderer
text={props.message.content}
type="assistant"
Expand Down Expand Up @@ -334,7 +345,7 @@ function RenderMessage(props: {
return (
<div className="flex items-start gap-3.5">
{userIcon}
<div className="px-3.5 py-2 rounded-xl border bg-card relative">
<div className="px-3.5 py-2 rounded-xl border bg-card relative fade-in-0 duration-300 animate-in">
<StyledMarkdownRenderer
text={props.message.content}
type="user"
Expand All @@ -349,7 +360,9 @@ function RenderMessage(props: {
return (
<div className="flex items-center gap-3.5">
{aiIcon}
<TextShimmer text="Thinking..." className="text-sm md:text-base" />
<div className="fade-in-0 duration-300 animate-in">
<TextShimmer text="Thinking..." className="text-sm" />
</div>
</div>
);
}
Expand All @@ -371,7 +384,7 @@ function StyledMarkdownRenderer(props: {
}) {
return (
<MarkdownRenderer
className="text-sm md:text-base text-foreground [&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0 leading-relaxed"
className="text-sm text-foreground [&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0 leading-relaxed"
code={{
className: "bg-card",
ignoreFormattingErrors: true,
Expand Down
Loading
Loading