better chat (#416)

* better chat

* fixes

* fix
This commit is contained in:
Chris Tate
2026-02-11 14:17:53 -06:00
committed by GitHub
parent ffe29b8a26
commit 66a11aeb4c
9 changed files with 2267 additions and 284 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+6 -1
View File
@@ -14,17 +14,21 @@
"@mdx-js/mdx": "^3.1.1", "@mdx-js/mdx": "^3.1.1",
"@mdx-js/react": "^3.1.1", "@mdx-js/react": "^3.1.1",
"@next/mdx": "^16.1.6", "@next/mdx": "^16.1.6",
"@streamdown/code": "^1.0.2",
"@upstash/ratelimit": "^2.0.8", "@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.36.2", "@upstash/redis": "^1.36.2",
"ai": "^6.0.78", "ai": "^6.0.78",
"bash-tool": "^1.3.14", "bash-tool": "^1.3.14",
"clsx": "^2.1.1",
"just-bash": "^2.9.6", "just-bash": "^2.9.6",
"next": "16.1.1", "next": "16.1.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"shiki": "^3.21.0", "shiki": "^3.21.0",
"streamdown": "^2.1.0" "streamdown": "^2.1.0",
"tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -35,6 +39,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5" "typescript": "^5"
} }
} }
+1686
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -26,7 +26,8 @@ When answering questions:
- Always base your answers on the actual documentation content - Always base your answers on the actual documentation content
- Be concise and accurate - Be concise and accurate
- If the docs don't cover a topic, say so honestly - If the docs don't cover a topic, say so honestly
- Do NOT include source references or file paths in your response`; - Do NOT include source references or file paths in your response
- Do NOT use emojis in your responses`;
async function loadDocsFiles(): Promise<Record<string, string>> { async function loadDocsFiles(): Promise<Record<string, string>> {
const files: Record<string, string> = {}; const files: Record<string, string> = {};
+26 -25
View File
@@ -1,4 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@source "../../node_modules/streamdown/dist/index.js";
:root { :root {
--radius: 0.5rem; --radius: 0.5rem;
@@ -78,29 +81,23 @@
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
} }
* {
border-color: var(--border);
}
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: var(--font-geist), system-ui, sans-serif; font-family: var(--font-geist), system-ui, sans-serif;
} }
/* Scrollbar */ /* Hide page scrollbar */
::-webkit-scrollbar { html {
width: 6px; scrollbar-width: none;
height: 6px;
} }
::-webkit-scrollbar-track { html::-webkit-scrollbar {
background: transparent; display: none;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
} }
/* Code blocks */ /* Code blocks */
@@ -183,7 +180,7 @@ code {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
line-height: 1.7; line-height: 1.7;
color: var(--muted-foreground); color: var(--muted-foreground);
font-size: 0.9375rem; font-size: 0.875rem;
} }
.prose ul, .prose ol { .prose ul, .prose ol {
@@ -194,7 +191,7 @@ code {
.prose li { .prose li {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--muted-foreground); color: var(--muted-foreground);
font-size: 0.9375rem; font-size: 0.875rem;
line-height: 1.6; line-height: 1.6;
} }
@@ -254,17 +251,21 @@ code {
animation: tool-shimmer 1.5s ease-in-out infinite; animation: tool-shimmer 1.5s ease-in-out infinite;
} }
/* Dark mode: use page bg color for borders in chat message content */ /* Override prose text color in chat so agent responses use primary foreground */
.dark .docs-chat-content * {
border-color: var(--background);
}
/* Override prose text color inside chat content so agent responses appear brighter */
.docs-chat-content p, .docs-chat-content p,
.docs-chat-content li, .docs-chat-content li,
.docs-chat-content td { .docs-chat-content td,
.docs-chat-content th,
.docs-chat-content strong,
.docs-chat-content code {
color: var(--foreground); color: var(--foreground);
opacity: 0.9; }
/* Reset global pre styles inside chat so Streamdown's own styling takes effect */
.docs-chat-content pre {
border: none;
border-radius: 0;
padding: revert-layer;
} }
/* Fix list rendering in chat content */ /* Fix list rendering in chat content */
+16 -2
View File
@@ -6,6 +6,7 @@ import { MobileNavProvider } from "@/components/mobile-nav-context";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import { Sidebar } from "@/components/sidebar"; import { Sidebar } from "@/components/sidebar";
import { DocsChat } from "@/components/docs-chat"; import { DocsChat } from "@/components/docs-chat";
import { cookies } from "next/headers";
const geist = Geist({ const geist = Geist({
variable: "--font-geist", variable: "--font-geist",
@@ -22,13 +23,26 @@ export const metadata: Metadata = {
description: "Headless browser automation CLI for AI agents", description: "Headless browser automation CLI for AI agents",
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const cookieStore = await cookies();
const chatOpen = cookieStore.get("docs-chat-open")?.value === "true";
const chatWidth = Number(cookieStore.get("docs-chat-width")?.value) || 400;
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head>
{chatOpen && (
<style
dangerouslySetInnerHTML={{
__html: `@media(min-width:640px){body{padding-right:${chatWidth}px}}`,
}}
/>
)}
</head>
<body <body
className={`${geist.variable} ${geistMono.variable} antialiased bg-background text-foreground`} className={`${geist.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
> >
@@ -45,7 +59,7 @@ export default function RootLayout({
</div> </div>
</main> </main>
</div> </div>
<DocsChat /> <DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
</MobileNavProvider> </MobileNavProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
+252 -151
View File
@@ -1,14 +1,29 @@
"use client"; "use client";
import { useRef, useEffect, useState } from "react"; import {
useRef,
useEffect,
useState,
useCallback,
type PointerEvent as ReactPointerEvent,
} from "react";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
import Link from "next/link"; import Link from "next/link";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
const STORAGE_KEY = "docs-chat-messages"; const STORAGE_KEY = "docs-chat-messages";
const transport = new DefaultChatTransport({ api: "/api/docs-chat" }); const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
const DESKTOP_DEFAULT_WIDTH = 400;
const DESKTOP_MIN_WIDTH = 300;
const DESKTOP_MAX_WIDTH = 700;
function setCookie(name: string, value: string) {
document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
}
const TOOL_LABELS: Record< const TOOL_LABELS: Record<
string, string,
{ label: string; pastLabel: string; argKey?: string } { label: string; pastLabel: string; argKey?: string }
@@ -84,12 +99,12 @@ function ToolCallDisplay({
return ( return (
<div className="text-xs py-0.5 min-w-0"> <div className="text-xs py-0.5 min-w-0">
{isRunning ? ( {isRunning ? (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0"> <span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0 max-w-full">
<span className="shrink-0">{displayLabel}</span> <span className="shrink-0">{displayLabel}</span>
{argEl} {argEl}
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0"> <span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0 max-w-full">
<span className="shrink-0">{displayLabel}</span> <span className="shrink-0">{displayLabel}</span>
{argEl} {argEl}
{isError && <span className="text-destructive">failed</span>} {isError && <span className="text-destructive">failed</span>}
@@ -107,20 +122,109 @@ const SUGGESTIONS = [
"How do I use CDP mode?", "How do I use CDP mode?",
]; ];
export function DocsChat() { export function DocsChat({
const [open, setOpen] = useState(false); defaultOpen = false,
defaultWidth = DESKTOP_DEFAULT_WIDTH,
}: {
defaultOpen?: boolean;
defaultWidth?: number;
}) {
const [open, setOpen] = useState(defaultOpen);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [focused, setFocused] = useState(false); const [isDesktop, setIsDesktop] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const [hasMounted, setHasMounted] = useState(false);
const [desktopWidth, setDesktopWidth] = useState(
Math.min(DESKTOP_MAX_WIDTH, Math.max(DESKTOP_MIN_WIDTH, defaultWidth)),
);
const messagesScrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false); const restoredRef = useRef(false);
const isDraggingRef = useRef(false);
const { messages, sendMessage, status, setMessages, error } = useChat({ const { messages, sendMessage, status, setMessages, error } = useChat({
transport, transport,
}); });
const isLoading = status === "streaming" || status === "submitted"; const isLoading = status === "streaming" || status === "submitted";
const showMessages = messages.length > 0 || !!error || isLoading;
// Detect desktop vs mobile. Close sidebar on mobile if it was open from cookie.
useEffect(() => {
const mq = window.matchMedia("(min-width: 640px)");
setIsDesktop(mq.matches);
setHasMounted(true);
// If on mobile but sidebar was open from cookie, close it
if (!mq.matches && defaultOpen) {
setOpen(false);
}
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Persist open state to cookie (only after mount to avoid overwriting on mobile)
useEffect(() => {
if (hasMounted) {
setCookie("docs-chat-open", String(open));
}
}, [open, hasMounted]);
// Push page content on desktop when pane is open.
// Use padding on body so the page scrollbar stays at the viewport edge (behind the sidebar)
// instead of appearing right next to the sidebar's scrollbar.
useEffect(() => {
const body = document.body;
if (isDesktop && open) {
body.style.paddingRight = `${desktopWidth}px`;
if (!isDraggingRef.current) {
body.style.transition = "padding-right 150ms ease";
}
} else if (isDesktop) {
body.style.paddingRight = "0px";
body.style.transition = "padding-right 150ms ease";
}
return () => {
body.style.paddingRight = "0px";
body.style.transition = "";
};
}, [isDesktop, open, desktopWidth]);
// Resize handle drag
const handleResizePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
isDraggingRef.current = true;
document.documentElement.style.transition = "none";
const startX = e.clientX;
const startWidth = desktopWidth;
const onPointerMove = (ev: globalThis.PointerEvent) => {
const delta = startX - ev.clientX;
const newWidth = Math.min(
DESKTOP_MAX_WIDTH,
Math.max(DESKTOP_MIN_WIDTH, startWidth + delta),
);
setDesktopWidth(newWidth);
};
const onPointerUp = () => {
isDraggingRef.current = false;
document.documentElement.style.transition = "";
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
},
[desktopWidth],
);
// Persist width to cookie
useEffect(() => {
setCookie("docs-chat-width", String(desktopWidth));
}, [desktopWidth]);
// Restore messages from sessionStorage on mount // Restore messages from sessionStorage on mount
useEffect(() => { useEffect(() => {
@@ -154,77 +258,62 @@ export function DocsChat() {
} }
}, [messages, isLoading]); }, [messages, isLoading]);
// Auto-open when new messages arrive (but not on initial restore) // Cmd+K to open sidebar and focus prompt, Escape to close
const prevMessageCount = useRef<number | null>(null);
const initializedRef = useRef(false);
useEffect(() => {
// Skip until after the first sessionStorage restore cycle
if (!initializedRef.current) {
// Wait one tick after mount to let restore settle
const id = requestAnimationFrame(() => {
prevMessageCount.current = messages.length;
initializedRef.current = true;
});
return () => cancelAnimationFrame(id);
}
if (
prevMessageCount.current !== null &&
messages.length > prevMessageCount.current
) {
setOpen(true);
}
prevMessageCount.current = messages.length;
}, [messages.length]);
// Scroll to bottom when messages change or error occurs
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, error]);
// Cmd+K to focus prompt, Esc to close
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) { if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
inputRef.current?.focus(); setOpen((prev) => {
if (!prev) {
setTimeout(() => inputRef.current?.focus(), 200);
} }
if (e.key === "Escape" && open) { return !prev;
});
}
if (e.key === "Escape" && open && isDesktop) {
setOpen(false); setOpen(false);
inputRef.current?.blur();
} }
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [open]); }, [open, isDesktop]);
// Close message area when clicking outside // Auto-focus input when opened
useEffect(() => { useEffect(() => {
if (!open) return; if (open) {
const handleClickOutside = (e: MouseEvent) => { const timer = setTimeout(() => inputRef.current?.focus(), 200);
if ( return () => clearTimeout(timer);
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
} }
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]); }, [open]);
const handleSubmit = (e: React.FormEvent) => { // Auto-open when error occurs
useEffect(() => {
if (error) setOpen(true);
}, [error]);
// Scroll to bottom when messages change or error occurs
useEffect(() => {
const el = messagesScrollRef.current;
if (!el) return;
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}, [messages, error]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!input.trim() || isLoading) return; if (!input.trim() || isLoading) return;
sendMessage({ text: input }); sendMessage({ text: input });
setInput(""); setInput("");
}; },
[input, isLoading, sendMessage],
);
const handleClear = () => { const handleClear = useCallback(() => {
setMessages([]); setMessages([]);
sessionStorage.removeItem(STORAGE_KEY); sessionStorage.removeItem(STORAGE_KEY);
setOpen(false); }, [setMessages]);
inputRef.current?.focus();
};
const hasVisibleContent = ( const hasVisibleContent = (
parts: (typeof messages)[number]["parts"], parts: (typeof messages)[number]["parts"],
@@ -234,57 +323,14 @@ export function DocsChat() {
); );
}; };
// Auto-open when error occurs // Shared chat panel content used by both desktop and mobile
useEffect(() => { const chatPanel = (
if (error) setOpen(true); <>
}, [error]); {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
const showMessages = open && (messages.length > 0 || !!error); <span className="text-sm font-medium">agent-browser Docs</span>
const showSuggestions = focused && messages.length === 0 && !isLoading; <div className="flex items-center gap-3">
return (
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
<div
ref={containerRef}
className={`mx-auto px-4 pb-4 *:pointer-events-auto transition-all duration-300 ${focused || showMessages ? "max-w-xl" : "max-w-56"}`}
>
<div
className={`border rounded-lg overflow-hidden ${focused || showMessages ? "border-background" : "border-[var(--chat-bg)]"}`}
style={{ backgroundColor: "var(--chat-bg)" }}
>
{/* Suggestions panel */}
{showSuggestions && (
<div>
<div className="flex items-center px-4 py-2 border-b border-background shrink-0">
<span className="text-xs font-medium text-muted-foreground">
agent-browser Docs
</span>
</div>
<div className="flex flex-wrap gap-2 p-3">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onMouseDown={(e) => {
e.preventDefault();
setOpen(true);
sendMessage({ text: s });
}}
className="text-xs px-3 py-1.5 rounded-full border border-background bg-background font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{s}
</button>
))}
</div>
</div>
)}
{/* Messages panel */}
{showMessages && ( {showMessages && (
<div className="max-h-[60vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-2 border-b border-background shrink-0">
<span className="text-xs font-medium text-muted-foreground">
agent-browser Docs
</span>
<button <button
onClick={handleClear} onClick={handleClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors" className="text-xs text-muted-foreground hover:text-foreground transition-colors"
@@ -292,14 +338,34 @@ export function DocsChat() {
> >
Clear Clear
</button> </button>
)}
<button
onClick={() => setOpen(false)}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close panel"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div> </div>
</div>
{/* Content: suggestions or messages */}
{showMessages ? (
<div <div
className="p-4 space-y-4 overflow-y-auto" ref={messagesScrollRef}
onClick={(e) => { className="flex-1 min-h-0 p-4 space-y-4 overflow-y-auto"
if ((e.target as HTMLElement).closest("a")) {
setOpen(false);
}
}}
> >
{messages.map((message) => { {messages.map((message) => {
if (!hasVisibleContent(message.parts)) return null; if (!hasVisibleContent(message.parts)) return null;
@@ -322,7 +388,7 @@ export function DocsChat() {
return ( return (
<div <div
key={i} key={i}
className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none" className="docs-chat-content text-sm text-foreground leading-relaxed prose prose-sm dark:prose-invert max-w-none"
> >
<Streamdown>{part.text}</Streamdown> <Streamdown>{part.text}</Streamdown>
</div> </div>
@@ -330,10 +396,7 @@ export function DocsChat() {
} }
if (isToolPart(part)) { if (isToolPart(part)) {
return ( return (
<ToolCallDisplay <ToolCallDisplay key={part.toolCallId} part={part} />
key={part.toolCallId}
part={part}
/>
); );
} }
return null; return null;
@@ -351,14 +414,28 @@ export function DocsChat() {
return parsed.message || parsed.error || error.message; return parsed.message || parsed.error || error.message;
} catch { } catch {
return ( return (
error.message || error.message || "Something went wrong. Please try again."
"Something went wrong. Please try again."
); );
} }
})()} })()}
</div> </div>
)} )}
<div ref={messagesEndRef} /> </div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex flex-wrap gap-2 p-4">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => {
sendMessage({ text: s });
}}
className="text-xs px-3 py-1.5 rounded-full border bg-secondary font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{s}
</button>
))}
</div> </div>
</div> </div>
)} )}
@@ -366,21 +443,8 @@ export function DocsChat() {
{/* Input bar */} {/* Input bar */}
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
onClick={() => inputRef.current?.focus()} className="flex items-end gap-2 px-4 py-3 border-t shrink-0"
className={`relative flex items-end gap-2 px-3 py-2 cursor-text${showMessages ? " border-t border-background" : ""}`}
> >
{!input && (
<div className="absolute inset-0 flex items-center px-3 pointer-events-none">
<span className="text-sm text-muted-foreground truncate flex-1">
Ask a question...
</span>
{!focused && !showMessages && (
<span className="text-muted-foreground/40 font-mono text-xs shrink-0">
&#8984;K
</span>
)}
</div>
)}
<textarea <textarea
ref={inputRef} ref={inputRef}
value={input} value={input}
@@ -390,29 +454,20 @@ export function DocsChat() {
e.target.style.height = `${e.target.scrollHeight}px`; e.target.style.height = `${e.target.scrollHeight}px`;
}} }}
rows={1} rows={1}
onFocus={() => { enterKeyHint="send"
setFocused(true); placeholder="Ask a question..."
if (messages.length > 0) setOpen(true);
}}
onBlur={() => {
setFocused(false);
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
inputRef.current?.blur();
}
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleSubmit(e); handleSubmit(e);
} }
}} }}
className="flex-1 bg-transparent text-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed relative z-10" className="flex-1 bg-transparent text-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed placeholder:text-muted-foreground"
/> />
<button <button
type="submit" type="submit"
disabled={isLoading || !input.trim()} disabled={isLoading || !input.trim()}
className={`bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30${!focused && !showMessages ? " hidden" : ""}`} className="bg-primary text-primary-foreground rounded-full p-1.5 hover:bg-primary/90 transition-colors disabled:opacity-30 shrink-0"
aria-label="Send message" aria-label="Send message"
> >
<svg <svg
@@ -430,8 +485,54 @@ export function DocsChat() {
</svg> </svg>
</button> </button>
</form> </form>
</div> </>
</div> );
</div>
return (
<>
{/* Ask AI trigger button */}
{!open && (
<button
onClick={() => setOpen(true)}
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border bg-background text-primary shadow-lg hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
aria-label="Ask AI"
>
Ask AI
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
<span>&#8984;</span>K
</kbd>
</button>
)}
{/* Desktop: resizable side pane -- always rendered, hidden on mobile via CSS */}
<aside
className={`hidden sm:flex fixed top-0 right-0 bottom-0 z-40 border-l bg-background transition-transform duration-150 ease-in-out ${open ? "translate-x-0" : "translate-x-full"}`}
style={{ width: desktopWidth }}
aria-hidden={!open}
>
{/* Resize handle */}
<div
onPointerDown={handleResizePointerDown}
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-col-resize hover:bg-ring/30 active:bg-ring/50 transition-colors z-10"
/>
<div className="flex flex-col flex-1 min-w-0">{chatPanel}</div>
</aside>
{/* Mobile: Sheet overlay/drawer -- only after mount to avoid flash on desktop */}
{hasMounted && !isDesktop && (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
showCloseButton={false}
overlayClassName="bg-background!"
className="inset-0! w-full! h-full! max-w-none! p-0 flex flex-col"
style={{ backgroundColor: "var(--background)", opacity: 1 }}
>
<SheetTitle className="sr-only">AI Chat</SheetTitle>
{chatPanel}
</SheetContent>
</Sheet>
)}
</>
); );
} }
+147
View File
@@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
overlayClassName,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
overlayClassName?: string
}) {
return (
<SheetPortal>
<SheetOverlay className={overlayClassName} />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}