| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542 |
- "use client";
- import {
- useRef,
- useEffect,
- useState,
- useCallback,
- type PointerEvent as ReactPointerEvent,
- } from "react";
- import { useChat } from "@ai-sdk/react";
- import { DefaultChatTransport } from "ai";
- import { Streamdown } from "streamdown";
- import Link from "next/link";
- import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
- const STORAGE_KEY = "docs-chat-messages";
- 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<
- string,
- { label: string; pastLabel: string; argKey?: string }
- > = {
- readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
- bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
- };
- function isToolPart(part: { type: string }): part is {
- type: string;
- toolCallId: string;
- toolName?: string;
- state: string;
- input?: Record<string, unknown>;
- output?: unknown;
- errorText?: string;
- } {
- return part.type.startsWith("tool-") || part.type === "dynamic-tool";
- }
- function getToolName(part: { type: string; toolName?: string }): string {
- if (part.type === "dynamic-tool") return part.toolName ?? "tool";
- return part.type.replace(/^tool-/, "");
- }
- function ToolCallDisplay({
- part,
- }: {
- part: {
- type: string;
- toolCallId: string;
- toolName?: string;
- state: string;
- input?: Record<string, unknown>;
- output?: unknown;
- errorText?: string;
- };
- }) {
- const toolName = getToolName(part);
- const config = TOOL_LABELS[toolName] ?? {
- label: toolName,
- pastLabel: toolName,
- };
- const isDone = part.state === "output-available";
- const isError = part.state === "output-error";
- const isRunning = !isDone && !isError;
- const displayLabel = isRunning ? config.label : config.pastLabel;
- const args = (part.input ?? {}) as Record<string, unknown>;
- const argValue = config.argKey ? args[config.argKey] : undefined;
- const argPreview =
- argValue != null
- ? String(argValue)
- .replace(/\/workspace\//g, "/")
- .replace(/\.md$/, "")
- .replace(/\/index$/, "")
- : "";
- // Link to the docs page if it's a /docs/ path from readFile
- const docsLink =
- toolName === "readFile" &&
- (argPreview === "/docs" || argPreview.startsWith("/docs/"))
- ? argPreview
- : null;
- const argEl = argPreview ? (
- docsLink ? (
- <Link href={docsLink} className="truncate underline underline-offset-2">
- {argPreview}
- </Link>
- ) : (
- <span className="truncate">{argPreview}</span>
- )
- ) : null;
- return (
- <div className="text-xs py-0.5 min-w-0">
- {isRunning ? (
- <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>
- {argEl}
- </span>
- ) : (
- <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>
- {argEl}
- {isError && <span className="text-destructive">failed</span>}
- </span>
- )}
- </div>
- );
- }
- const SUGGESTIONS = [
- "What is json-render?",
- "How do I install it?",
- "How does streaming work?",
- "What components are available?",
- "How do I create a custom schema?",
- ];
- export function DocsChat({
- defaultOpen = false,
- defaultWidth = DESKTOP_DEFAULT_WIDTH,
- }: {
- defaultOpen?: boolean;
- defaultWidth?: number;
- }) {
- const [open, setOpen] = useState(defaultOpen);
- const [input, setInput] = useState("");
- const [isDesktop, setIsDesktop] = useState(false);
- 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 restoredRef = useRef(false);
- const isDraggingRef = useRef(false);
- const { messages, sendMessage, status, setMessages, error } = useChat({
- transport,
- });
- 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
- useEffect(() => {
- if (restoredRef.current) return;
- restoredRef.current = true;
- try {
- const stored = sessionStorage.getItem(STORAGE_KEY);
- if (stored) {
- const parsed = JSON.parse(stored);
- if (Array.isArray(parsed) && parsed.length > 0) {
- setMessages(parsed);
- }
- }
- } catch {
- // ignore parse errors
- }
- }, [setMessages]);
- // Save completed messages to sessionStorage
- useEffect(() => {
- if (!restoredRef.current) return;
- if (isLoading) return;
- if (messages.length === 0) {
- sessionStorage.removeItem(STORAGE_KEY);
- return;
- }
- try {
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
- } catch {
- // ignore quota errors
- }
- }, [messages, isLoading]);
- // Cmd+K to open sidebar and focus prompt, Escape to close
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
- e.preventDefault();
- setOpen((prev) => {
- if (!prev) {
- setTimeout(() => inputRef.current?.focus(), 200);
- }
- return !prev;
- });
- }
- if (e.key === "Escape" && open && isDesktop) {
- setOpen(false);
- }
- };
- document.addEventListener("keydown", handleKeyDown);
- return () => document.removeEventListener("keydown", handleKeyDown);
- }, [open, isDesktop]);
- // Auto-focus input when opened
- useEffect(() => {
- if (open) {
- const timer = setTimeout(() => inputRef.current?.focus(), 200);
- return () => clearTimeout(timer);
- }
- }, [open]);
- // 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();
- if (!input.trim() || isLoading) return;
- sendMessage({ text: input });
- setInput("");
- },
- [input, isLoading, sendMessage],
- );
- const handleClear = useCallback(() => {
- setMessages([]);
- sessionStorage.removeItem(STORAGE_KEY);
- }, [setMessages]);
- const hasVisibleContent = (
- parts: (typeof messages)[number]["parts"],
- ): boolean => {
- return parts.some(
- (p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
- );
- };
- // Shared chat panel content used by both desktop and mobile
- const chatPanel = (
- <>
- {/* Header */}
- <div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
- <span className="text-sm font-medium">json-render Docs</span>
- <div className="flex items-center gap-3">
- {showMessages && (
- <button
- onClick={handleClear}
- className="text-xs text-muted-foreground hover:text-foreground transition-colors"
- aria-label="Clear conversation"
- >
- Clear
- </button>
- )}
- {isDesktop && (
- <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>
- {/* Content: suggestions or messages */}
- {showMessages ? (
- <div
- ref={messagesScrollRef}
- className="flex-1 min-h-0 p-4 space-y-4 overflow-y-auto"
- >
- {messages.map((message) => {
- if (!hasVisibleContent(message.parts)) return null;
- return (
- <div key={message.id}>
- {message.role === "user" ? (
- <div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
- {message.parts
- .filter(
- (p): p is Extract<typeof p, { type: "text" }> =>
- p.type === "text",
- )
- .map((p) => p.text)
- .join("")}
- </div>
- ) : (
- <div className="space-y-2">
- {message.parts.map((part, i) => {
- if (part.type === "text" && part.text) {
- return (
- <div
- key={i}
- className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none"
- >
- <Streamdown>{part.text}</Streamdown>
- </div>
- );
- }
- if (isToolPart(part)) {
- return (
- <ToolCallDisplay key={part.toolCallId} part={part} />
- );
- }
- return null;
- })}
- </div>
- )}
- </div>
- );
- })}
- {error && (
- <div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
- {(() => {
- try {
- const parsed = JSON.parse(error.message);
- return parsed.message || parsed.error || error.message;
- } catch {
- return (
- error.message || "Something went wrong. Please try again."
- );
- }
- })()}
- </div>
- )}
- </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>
- )}
- {/* Input bar */}
- <form
- onSubmit={handleSubmit}
- className="flex items-end gap-2 px-4 py-3 border-t shrink-0"
- >
- <textarea
- ref={inputRef}
- value={input}
- onChange={(e) => {
- setInput(e.target.value);
- e.target.style.height = "auto";
- e.target.style.height = `${e.target.scrollHeight}px`;
- }}
- rows={1}
- enterKeyHint="send"
- placeholder="Ask a question..."
- onKeyDown={(e) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- 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 placeholder:text-muted-foreground"
- />
- <button
- type="submit"
- disabled={isLoading || !input.trim()}
- 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"
- >
- <svg
- width="16"
- height="16"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- >
- <line x1="12" y1="19" x2="12" y2="5" />
- <polyline points="5 12 12 5 19 12" />
- </svg>
- </button>
- </form>
- </>
- );
- 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>⌘</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"
- 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>
- )}
- </>
- );
- }
|