mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-25 00:22:43 -05:00
docs: improve ai-chat for mobile (#8505)
This commit is contained in:
@@ -18,6 +18,14 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Markdown } from "./markdown";
|
||||
import { MessageFeedback } from "./message-feedback";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "./ui/drawer";
|
||||
|
||||
// ─── Context ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -108,24 +116,106 @@ export function AIChatTrigger({
|
||||
|
||||
// ─── Panel ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const LG_BREAKPOINT = 1024;
|
||||
|
||||
function useIsDesktop() {
|
||||
const [isDesktop, setIsDesktop] = useState<boolean | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`);
|
||||
const onChange = () => setIsDesktop(mql.matches);
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsDesktop(mql.matches);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
export function AIChatPanel() {
|
||||
const isDesktop = useIsDesktop();
|
||||
useAIChatHotKey();
|
||||
|
||||
// SSR / hydration: render nothing until we know the viewport
|
||||
if (isDesktop === undefined) return null;
|
||||
|
||||
return isDesktop ? <DesktopPanel /> : <MobileDrawerPanel />;
|
||||
}
|
||||
|
||||
function useVisualViewportHeight() {
|
||||
const [height, setHeight] = useState<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const update = () => setHeight(vv.height);
|
||||
update();
|
||||
vv.addEventListener("resize", update);
|
||||
return () => vv.removeEventListener("resize", update);
|
||||
}, []);
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
function MobileDrawerPanel() {
|
||||
const { open, setOpen } = useAIChat();
|
||||
const vvHeight = useVisualViewportHeight();
|
||||
|
||||
// 85% of the visual viewport (shrinks when keyboard opens)
|
||||
const drawerHeight = vvHeight ? vvHeight * 0.85 : undefined;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
repositionInputs={false}
|
||||
handleOnly
|
||||
>
|
||||
<DrawerContent
|
||||
style={
|
||||
drawerHeight
|
||||
? { height: drawerHeight, maxHeight: drawerHeight }
|
||||
: undefined
|
||||
}
|
||||
className={drawerHeight ? undefined : "h-[85dvh] max-h-[85dvh]"}
|
||||
>
|
||||
<DrawerHeader className="flex flex-row items-center gap-2 border-b">
|
||||
<div className="flex-1 text-left">
|
||||
<DrawerTitle className="text-xs font-medium">AI Chat</DrawerTitle>
|
||||
<DrawerDescription className="text-[10px]">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href="https://inkeep.com"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
Inkeep AI
|
||||
</a>
|
||||
</DrawerDescription>
|
||||
</div>
|
||||
<DrawerClose
|
||||
aria-label="Close"
|
||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col flex-1 w-full min-h-0 overflow-hidden px-2 pb-3">
|
||||
<PanelMessages className="flex-1" />
|
||||
<PanelInput />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopPanel() {
|
||||
const { open } = useAIChat();
|
||||
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const startX = useRef(0);
|
||||
const startWidth = useRef(panelWidth);
|
||||
useAIChatHotKey();
|
||||
|
||||
// Mobile: lock body scroll when panel is open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const mq = window.matchMedia("(max-width: 1023px)");
|
||||
if (!mq.matches) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
@@ -153,34 +243,17 @@ export function AIChatPanel() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
<div
|
||||
className="fixed inset-0 z-[200] backdrop-blur-xs bg-black/50 lg:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden overscroll-contain z-200 bg-background text-foreground",
|
||||
"[--ai-chat-width:400px]",
|
||||
// Mobile
|
||||
"max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:h-[80dvh] max-lg:border-t max-lg:shadow-[0_-8px_50px_0px_rgba(0,0,0,0.12)] max-lg:dark:shadow-[0_-8px_50px_0px_rgba(255,255,255,0.07)]",
|
||||
// Desktop
|
||||
"lg:fixed lg:top-0 lg:inset-e-0 lg:h-dvh lg:border-s lg:shadow-[-8px_0_24px_-4px_rgba(0,0,0,0.1)] lg:dark:shadow-[-8px_0_30px_-2px_rgba(0,0,0,0.7)]",
|
||||
"fixed top-0 inset-e-0 h-dvh border-s",
|
||||
"shadow-[-8px_0_24px_-4px_rgba(0,0,0,0.1)] dark:shadow-[-8px_0_30px_-2px_rgba(0,0,0,0.7)]",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--ai-chat-width": `${panelWidth}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
style={{ width: panelWidth }}
|
||||
>
|
||||
{/* Resize handle (desktop) */}
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 w-1 cursor-col-resize hover:bg-foreground/10 active:bg-foreground/15 transition-colors hidden lg:block z-10"
|
||||
className="absolute top-0 bottom-0 left-0 w-1 cursor-col-resize hover:bg-foreground/10 active:bg-foreground/15 transition-colors z-10"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
startX.current = e.clientX;
|
||||
@@ -188,7 +261,10 @@ export function AIChatPanel() {
|
||||
setDragging(true);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col size-full min-h-0 overflow-hidden p-2 pb-3 max-lg:h-full lg:p-3 lg:w-(--ai-chat-width)">
|
||||
<div
|
||||
className="flex flex-col size-full min-h-0 overflow-hidden p-3"
|
||||
style={{ width: panelWidth }}
|
||||
>
|
||||
<PanelHeader />
|
||||
<PanelMessages className="flex-1" />
|
||||
<PanelInput />
|
||||
@@ -207,9 +283,9 @@ function PanelHeader() {
|
||||
const { setOpen } = useAIChat();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border rounded-lg bg-foreground/2 p-2 mb-2">
|
||||
<div className="flex items-center gap-2 border-b px-3 pb-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium mb-1">AI Chat</p>
|
||||
<p className="text-sm font-medium">AI Chat</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Powered by{" "}
|
||||
<a
|
||||
@@ -289,7 +365,7 @@ function PanelMessages({ className, ...props }: ComponentProps<"div">) {
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"ai-chat-messages overflow-y-auto overscroll-contain min-w-0 min-h-0 flex flex-col py-4",
|
||||
"ai-chat-messages overflow-y-auto overscroll-contain min-w-0 min-h-0 flex flex-col py-4 select-text",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
@@ -328,6 +404,7 @@ function PanelMessages({ className, ...props }: ComponentProps<"div">) {
|
||||
function PanelInput() {
|
||||
const { status, sendMessage, stop, setMessages, messages, regenerate } =
|
||||
useChatContext();
|
||||
const isDesktop = useIsDesktop();
|
||||
const [input, setInput] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
@@ -419,12 +496,12 @@ function PanelInput() {
|
||||
}}
|
||||
disabled={isLoading}
|
||||
placeholder={isLoading ? "AI is answering..." : "Ask a question"}
|
||||
autoFocus
|
||||
autoFocus={isDesktop !== false}
|
||||
rows={1}
|
||||
style={{
|
||||
height: Math.max(inputMinHeight, 38),
|
||||
}}
|
||||
className="flex-1 resize-none text-[13px] bg-transparent pl-3.5 pr-1.5 py-2.5 placeholder:text-muted-foreground focus:outline-none overflow-y-auto"
|
||||
className="flex-1 resize-none text-base lg:text-[13px] bg-transparent pl-3.5 pr-1.5 py-2.5 placeholder:text-muted-foreground focus:outline-none overflow-y-auto"
|
||||
/>
|
||||
<div className="shrink-0 pb-1.5 pr-1.5">
|
||||
{isLoading ? (
|
||||
|
||||
134
landing/components/ui/drawer.tsx
Normal file
134
landing/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import type * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-200 bg-background/50 backdrop-blur-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content fixed z-200 flex h-auto flex-col bg-background",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -77,6 +77,7 @@
|
||||
"typesense": "^3.0.2",
|
||||
"typesense-fumadocs-adapter": "^0.3.0",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -943,6 +943,9 @@ importers:
|
||||
unist-util-visit:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0
|
||||
vaul:
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
zod:
|
||||
specifier: ^4.1.5
|
||||
version: 4.3.6
|
||||
|
||||
Reference in New Issue
Block a user