docs: improve ai-chat for mobile (#8505)

This commit is contained in:
Taesu
2026-03-09 13:02:38 +09:00
committed by GitHub
parent 2e3272c133
commit 4eb1131abd
4 changed files with 255 additions and 40 deletions

View File

@@ -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 ? (

View 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,
};

View File

@@ -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
View File

@@ -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