docs-chat.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. "use client";
  2. import {
  3. useRef,
  4. useEffect,
  5. useState,
  6. useCallback,
  7. type PointerEvent as ReactPointerEvent,
  8. } from "react";
  9. import { useChat } from "@ai-sdk/react";
  10. import { DefaultChatTransport } from "ai";
  11. import { Streamdown } from "streamdown";
  12. import Link from "next/link";
  13. import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
  14. const STORAGE_KEY = "docs-chat-messages";
  15. const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
  16. const DESKTOP_DEFAULT_WIDTH = 400;
  17. const DESKTOP_MIN_WIDTH = 300;
  18. const DESKTOP_MAX_WIDTH = 700;
  19. function setCookie(name: string, value: string) {
  20. document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
  21. }
  22. const TOOL_LABELS: Record<
  23. string,
  24. { label: string; pastLabel: string; argKey?: string }
  25. > = {
  26. readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
  27. bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
  28. };
  29. function isToolPart(part: { type: string }): part is {
  30. type: string;
  31. toolCallId: string;
  32. toolName?: string;
  33. state: string;
  34. input?: Record<string, unknown>;
  35. output?: unknown;
  36. errorText?: string;
  37. } {
  38. return part.type.startsWith("tool-") || part.type === "dynamic-tool";
  39. }
  40. function getToolName(part: { type: string; toolName?: string }): string {
  41. if (part.type === "dynamic-tool") return part.toolName ?? "tool";
  42. return part.type.replace(/^tool-/, "");
  43. }
  44. function ToolCallDisplay({
  45. part,
  46. }: {
  47. part: {
  48. type: string;
  49. toolCallId: string;
  50. toolName?: string;
  51. state: string;
  52. input?: Record<string, unknown>;
  53. output?: unknown;
  54. errorText?: string;
  55. };
  56. }) {
  57. const toolName = getToolName(part);
  58. const config = TOOL_LABELS[toolName] ?? {
  59. label: toolName,
  60. pastLabel: toolName,
  61. };
  62. const isDone = part.state === "output-available";
  63. const isError = part.state === "output-error";
  64. const isRunning = !isDone && !isError;
  65. const displayLabel = isRunning ? config.label : config.pastLabel;
  66. const args = (part.input ?? {}) as Record<string, unknown>;
  67. const argValue = config.argKey ? args[config.argKey] : undefined;
  68. const argPreview =
  69. argValue != null
  70. ? String(argValue)
  71. .replace(/\/workspace\//g, "/")
  72. .replace(/\.md$/, "")
  73. .replace(/\/index$/, "")
  74. : "";
  75. // Link to the docs page if it's a /docs/ path from readFile
  76. const docsLink =
  77. toolName === "readFile" &&
  78. (argPreview === "/docs" || argPreview.startsWith("/docs/"))
  79. ? argPreview
  80. : null;
  81. const argEl = argPreview ? (
  82. docsLink ? (
  83. <Link href={docsLink} className="truncate underline underline-offset-2">
  84. {argPreview}
  85. </Link>
  86. ) : (
  87. <span className="truncate">{argPreview}</span>
  88. )
  89. ) : null;
  90. return (
  91. <div className="text-xs py-0.5 min-w-0">
  92. {isRunning ? (
  93. <span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0 max-w-full">
  94. <span className="shrink-0">{displayLabel}</span>
  95. {argEl}
  96. </span>
  97. ) : (
  98. <span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0 max-w-full">
  99. <span className="shrink-0">{displayLabel}</span>
  100. {argEl}
  101. {isError && <span className="text-destructive">failed</span>}
  102. </span>
  103. )}
  104. </div>
  105. );
  106. }
  107. const SUGGESTIONS = [
  108. "What is json-render?",
  109. "How do I install it?",
  110. "How does streaming work?",
  111. "What components are available?",
  112. "How do I create a custom schema?",
  113. ];
  114. export function DocsChat({
  115. defaultOpen = false,
  116. defaultWidth = DESKTOP_DEFAULT_WIDTH,
  117. }: {
  118. defaultOpen?: boolean;
  119. defaultWidth?: number;
  120. }) {
  121. const [open, setOpen] = useState(defaultOpen);
  122. const [input, setInput] = useState("");
  123. const [isDesktop, setIsDesktop] = useState(false);
  124. const [hasMounted, setHasMounted] = useState(false);
  125. const [desktopWidth, setDesktopWidth] = useState(
  126. Math.min(DESKTOP_MAX_WIDTH, Math.max(DESKTOP_MIN_WIDTH, defaultWidth)),
  127. );
  128. const messagesScrollRef = useRef<HTMLDivElement>(null);
  129. const inputRef = useRef<HTMLTextAreaElement>(null);
  130. const restoredRef = useRef(false);
  131. const isDraggingRef = useRef(false);
  132. const { messages, sendMessage, status, setMessages, error } = useChat({
  133. transport,
  134. });
  135. const isLoading = status === "streaming" || status === "submitted";
  136. const showMessages = messages.length > 0 || !!error || isLoading;
  137. // Detect desktop vs mobile. Close sidebar on mobile if it was open from cookie.
  138. useEffect(() => {
  139. const mq = window.matchMedia("(min-width: 640px)");
  140. setIsDesktop(mq.matches);
  141. setHasMounted(true);
  142. // If on mobile but sidebar was open from cookie, close it
  143. if (!mq.matches && defaultOpen) {
  144. setOpen(false);
  145. }
  146. const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
  147. mq.addEventListener("change", handler);
  148. return () => mq.removeEventListener("change", handler);
  149. // eslint-disable-next-line react-hooks/exhaustive-deps
  150. }, []);
  151. // Persist open state to cookie (only after mount to avoid overwriting on mobile)
  152. useEffect(() => {
  153. if (hasMounted) {
  154. setCookie("docs-chat-open", String(open));
  155. }
  156. }, [open, hasMounted]);
  157. // Push page content on desktop when pane is open.
  158. // Use padding on body so the page scrollbar stays at the viewport edge (behind the sidebar)
  159. // instead of appearing right next to the sidebar's scrollbar.
  160. useEffect(() => {
  161. const body = document.body;
  162. if (isDesktop && open) {
  163. body.style.paddingRight = `${desktopWidth}px`;
  164. if (!isDraggingRef.current) {
  165. body.style.transition = "padding-right 150ms ease";
  166. }
  167. } else if (isDesktop) {
  168. body.style.paddingRight = "0px";
  169. body.style.transition = "padding-right 150ms ease";
  170. }
  171. return () => {
  172. body.style.paddingRight = "0px";
  173. body.style.transition = "";
  174. };
  175. }, [isDesktop, open, desktopWidth]);
  176. // Resize handle drag
  177. const handleResizePointerDown = useCallback(
  178. (e: ReactPointerEvent<HTMLDivElement>) => {
  179. e.preventDefault();
  180. isDraggingRef.current = true;
  181. document.documentElement.style.transition = "none";
  182. const startX = e.clientX;
  183. const startWidth = desktopWidth;
  184. const onPointerMove = (ev: globalThis.PointerEvent) => {
  185. const delta = startX - ev.clientX;
  186. const newWidth = Math.min(
  187. DESKTOP_MAX_WIDTH,
  188. Math.max(DESKTOP_MIN_WIDTH, startWidth + delta),
  189. );
  190. setDesktopWidth(newWidth);
  191. };
  192. const onPointerUp = () => {
  193. isDraggingRef.current = false;
  194. document.documentElement.style.transition = "";
  195. document.removeEventListener("pointermove", onPointerMove);
  196. document.removeEventListener("pointerup", onPointerUp);
  197. };
  198. document.addEventListener("pointermove", onPointerMove);
  199. document.addEventListener("pointerup", onPointerUp);
  200. },
  201. [desktopWidth],
  202. );
  203. // Persist width to cookie
  204. useEffect(() => {
  205. setCookie("docs-chat-width", String(desktopWidth));
  206. }, [desktopWidth]);
  207. // Restore messages from sessionStorage on mount
  208. useEffect(() => {
  209. if (restoredRef.current) return;
  210. restoredRef.current = true;
  211. try {
  212. const stored = sessionStorage.getItem(STORAGE_KEY);
  213. if (stored) {
  214. const parsed = JSON.parse(stored);
  215. if (Array.isArray(parsed) && parsed.length > 0) {
  216. setMessages(parsed);
  217. }
  218. }
  219. } catch {
  220. // ignore parse errors
  221. }
  222. }, [setMessages]);
  223. // Save completed messages to sessionStorage
  224. useEffect(() => {
  225. if (!restoredRef.current) return;
  226. if (isLoading) return;
  227. if (messages.length === 0) {
  228. sessionStorage.removeItem(STORAGE_KEY);
  229. return;
  230. }
  231. try {
  232. sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
  233. } catch {
  234. // ignore quota errors
  235. }
  236. }, [messages, isLoading]);
  237. // Cmd+K to open sidebar and focus prompt, Escape to close
  238. useEffect(() => {
  239. const handleKeyDown = (e: KeyboardEvent) => {
  240. if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
  241. e.preventDefault();
  242. setOpen((prev) => {
  243. if (!prev) {
  244. setTimeout(() => inputRef.current?.focus(), 200);
  245. }
  246. return !prev;
  247. });
  248. }
  249. if (e.key === "Escape" && open && isDesktop) {
  250. setOpen(false);
  251. }
  252. };
  253. document.addEventListener("keydown", handleKeyDown);
  254. return () => document.removeEventListener("keydown", handleKeyDown);
  255. }, [open, isDesktop]);
  256. // Auto-focus input when opened
  257. useEffect(() => {
  258. if (open) {
  259. const timer = setTimeout(() => inputRef.current?.focus(), 200);
  260. return () => clearTimeout(timer);
  261. }
  262. }, [open]);
  263. // Auto-open when error occurs
  264. useEffect(() => {
  265. if (error) setOpen(true);
  266. }, [error]);
  267. // Scroll to bottom when messages change or error occurs
  268. useEffect(() => {
  269. const el = messagesScrollRef.current;
  270. if (!el) return;
  271. requestAnimationFrame(() => {
  272. el.scrollTop = el.scrollHeight;
  273. });
  274. }, [messages, error]);
  275. const handleSubmit = useCallback(
  276. (e: React.FormEvent) => {
  277. e.preventDefault();
  278. if (!input.trim() || isLoading) return;
  279. sendMessage({ text: input });
  280. setInput("");
  281. },
  282. [input, isLoading, sendMessage],
  283. );
  284. const handleClear = useCallback(() => {
  285. setMessages([]);
  286. sessionStorage.removeItem(STORAGE_KEY);
  287. }, [setMessages]);
  288. const hasVisibleContent = (
  289. parts: (typeof messages)[number]["parts"],
  290. ): boolean => {
  291. return parts.some(
  292. (p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
  293. );
  294. };
  295. // Shared chat panel content used by both desktop and mobile
  296. const chatPanel = (
  297. <>
  298. {/* Header */}
  299. <div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
  300. <span className="text-sm font-medium">json-render Docs</span>
  301. <div className="flex items-center gap-3">
  302. {showMessages && (
  303. <button
  304. onClick={handleClear}
  305. className="text-xs text-muted-foreground hover:text-foreground transition-colors"
  306. aria-label="Clear conversation"
  307. >
  308. Clear
  309. </button>
  310. )}
  311. {isDesktop && (
  312. <button
  313. onClick={() => setOpen(false)}
  314. className="text-muted-foreground hover:text-foreground transition-colors"
  315. aria-label="Close panel"
  316. >
  317. <svg
  318. width="14"
  319. height="14"
  320. viewBox="0 0 24 24"
  321. fill="none"
  322. stroke="currentColor"
  323. strokeWidth="2"
  324. strokeLinecap="round"
  325. strokeLinejoin="round"
  326. >
  327. <line x1="18" y1="6" x2="6" y2="18" />
  328. <line x1="6" y1="6" x2="18" y2="18" />
  329. </svg>
  330. </button>
  331. )}
  332. </div>
  333. </div>
  334. {/* Content: suggestions or messages */}
  335. {showMessages ? (
  336. <div
  337. ref={messagesScrollRef}
  338. className="flex-1 min-h-0 p-4 space-y-4 overflow-y-auto"
  339. >
  340. {messages.map((message) => {
  341. if (!hasVisibleContent(message.parts)) return null;
  342. return (
  343. <div key={message.id}>
  344. {message.role === "user" ? (
  345. <div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
  346. {message.parts
  347. .filter(
  348. (p): p is Extract<typeof p, { type: "text" }> =>
  349. p.type === "text",
  350. )
  351. .map((p) => p.text)
  352. .join("")}
  353. </div>
  354. ) : (
  355. <div className="space-y-2">
  356. {message.parts.map((part, i) => {
  357. if (part.type === "text" && part.text) {
  358. return (
  359. <div
  360. key={i}
  361. className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none"
  362. >
  363. <Streamdown>{part.text}</Streamdown>
  364. </div>
  365. );
  366. }
  367. if (isToolPart(part)) {
  368. return (
  369. <ToolCallDisplay key={part.toolCallId} part={part} />
  370. );
  371. }
  372. return null;
  373. })}
  374. </div>
  375. )}
  376. </div>
  377. );
  378. })}
  379. {error && (
  380. <div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
  381. {(() => {
  382. try {
  383. const parsed = JSON.parse(error.message);
  384. return parsed.message || parsed.error || error.message;
  385. } catch {
  386. return (
  387. error.message || "Something went wrong. Please try again."
  388. );
  389. }
  390. })()}
  391. </div>
  392. )}
  393. </div>
  394. ) : (
  395. <div className="flex-1 min-h-0 flex flex-col">
  396. <div className="flex flex-wrap gap-2 p-4">
  397. {SUGGESTIONS.map((s) => (
  398. <button
  399. key={s}
  400. type="button"
  401. onClick={() => {
  402. sendMessage({ text: s });
  403. }}
  404. className="text-xs px-3 py-1.5 rounded-full border bg-secondary font-medium text-muted-foreground hover:text-foreground transition-colors"
  405. >
  406. {s}
  407. </button>
  408. ))}
  409. </div>
  410. </div>
  411. )}
  412. {/* Input bar */}
  413. <form
  414. onSubmit={handleSubmit}
  415. className="flex items-end gap-2 px-4 py-3 border-t shrink-0"
  416. >
  417. <textarea
  418. ref={inputRef}
  419. value={input}
  420. onChange={(e) => {
  421. setInput(e.target.value);
  422. e.target.style.height = "auto";
  423. e.target.style.height = `${e.target.scrollHeight}px`;
  424. }}
  425. rows={1}
  426. enterKeyHint="send"
  427. placeholder="Ask a question..."
  428. onKeyDown={(e) => {
  429. if (e.key === "Enter" && !e.shiftKey) {
  430. e.preventDefault();
  431. handleSubmit(e);
  432. }
  433. }}
  434. 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"
  435. />
  436. <button
  437. type="submit"
  438. disabled={isLoading || !input.trim()}
  439. className="bg-primary text-primary-foreground rounded-full p-1.5 hover:bg-primary/90 transition-colors disabled:opacity-30 shrink-0"
  440. aria-label="Send message"
  441. >
  442. <svg
  443. width="16"
  444. height="16"
  445. viewBox="0 0 24 24"
  446. fill="none"
  447. stroke="currentColor"
  448. strokeWidth="2"
  449. strokeLinecap="round"
  450. strokeLinejoin="round"
  451. >
  452. <line x1="12" y1="19" x2="12" y2="5" />
  453. <polyline points="5 12 12 5 19 12" />
  454. </svg>
  455. </button>
  456. </form>
  457. </>
  458. );
  459. return (
  460. <>
  461. {/* Ask AI trigger button */}
  462. {!open && (
  463. <button
  464. onClick={() => setOpen(true)}
  465. 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"
  466. aria-label="Ask AI"
  467. >
  468. Ask AI
  469. <kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
  470. <span>&#8984;</span>K
  471. </kbd>
  472. </button>
  473. )}
  474. {/* Desktop: resizable side pane — always rendered, hidden on mobile via CSS */}
  475. <aside
  476. 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"}`}
  477. style={{ width: desktopWidth }}
  478. aria-hidden={!open}
  479. >
  480. {/* Resize handle */}
  481. <div
  482. onPointerDown={handleResizePointerDown}
  483. 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"
  484. />
  485. <div className="flex flex-col flex-1 min-w-0">{chatPanel}</div>
  486. </aside>
  487. {/* Mobile: Sheet overlay/drawer — only after mount to avoid flash on desktop */}
  488. {hasMounted && !isDesktop && (
  489. <Sheet open={open} onOpenChange={setOpen}>
  490. <SheetContent
  491. side="right"
  492. overlayClassName="!bg-background"
  493. className="!inset-0 !w-full !h-full !max-w-none p-0 flex flex-col"
  494. style={{ backgroundColor: "var(--background)", opacity: 1 }}
  495. >
  496. <SheetTitle className="sr-only">AI Chat</SheetTitle>
  497. {chatPanel}
  498. </SheetContent>
  499. </Sheet>
  500. )}
  501. </>
  502. );
  503. }