ソースを参照

sidebar chat (#100)

* fix chat height

* fixes

* fixes

* fixes

* sidebar

* fix
Chris Tate 4 ヶ月 前
コミット
68ba7c6d7d

+ 2 - 1
apps/web/app/api/docs-chat/route.ts

@@ -26,7 +26,8 @@ When answering questions:
 - Always base your answers on the actual documentation content
 - Be concise and accurate
 - 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>> {
   const files: Record<string, string> = {};

+ 6 - 18
apps/web/app/globals.css

@@ -111,23 +111,15 @@
     @apply bg-transparent p-0;
   }
 
-  /* Custom scrollbar */
-  ::-webkit-scrollbar {
-    width: 8px;
-    height: 8px;
+  /* Hide page scrollbar */
+  html {
+    scrollbar-width: none;
   }
 
-  ::-webkit-scrollbar-track {
-    @apply bg-border;
+  html::-webkit-scrollbar {
+    display: none;
   }
 
-  ::-webkit-scrollbar-thumb {
-    @apply bg-background rounded;
-  }
-
-  ::-webkit-scrollbar-thumb:hover {
-    @apply bg-muted-foreground;
-  }
 }
 
 button {
@@ -145,11 +137,6 @@ button {
   animation: tool-shimmer 1.5s ease-in-out infinite;
 }
 
-/* Dark mode: use page bg color for borders in chat message content */
-.dark .docs-chat-content * {
-  border-color: var(--background);
-}
-
 /* Fix list rendering in chat content */
 .docs-chat-content ul,
 .docs-chat-content ol {
@@ -179,3 +166,4 @@ button {
   color: var(--shiki-dark) !important;
   background-color: var(--shiki-dark-bg) !important;
 }
+

+ 16 - 2
apps/web/app/layout.tsx

@@ -6,6 +6,7 @@ import { DocsChat } from "@/components/docs-chat";
 import { Analytics } from "@vercel/analytics/next";
 import { SpeedInsights } from "@vercel/speed-insights/next";
 import { PAGE_TITLES } from "@/lib/page-titles";
+import { cookies } from "next/headers";
 
 const geistSans = localFont({
   src: "./fonts/GeistVF.woff",
@@ -72,17 +73,30 @@ export const metadata: Metadata = {
   },
 };
 
-export default function RootLayout({
+export default async function RootLayout({
   children,
 }: Readonly<{
   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 (
     <html lang="en" suppressHydrationWarning>
+      <head>
+        {chatOpen && (
+          <style
+            dangerouslySetInnerHTML={{
+              __html: `@media(min-width:640px){body{padding-right:${chatWidth}px}}`,
+            }}
+          />
+        )}
+      </head>
       <body className={`${geistSans.variable} ${geistMono.variable}`}>
         <ThemeProvider>
           {children}
-          <DocsChat />
+          <DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
         </ThemeProvider>
         <Analytics />
         <SpeedInsights />

+ 348 - 246
apps/web/components/docs-chat.tsx

@@ -1,14 +1,29 @@
 "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 { 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 }
@@ -62,7 +77,7 @@ function ToolCallDisplay({
   const argPreview =
     argValue != null
       ? String(argValue)
-          .replace(/^\/workspace\//, "/")
+          .replace(/\/workspace\//g, "/")
           .replace(/\.md$/, "")
           .replace(/\/index$/, "")
       : "";
@@ -87,12 +102,12 @@ function ToolCallDisplay({
   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">
+        <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">
+        <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>}
@@ -110,20 +125,109 @@ const SUGGESTIONS = [
   "How do I create a custom schema?",
 ];
 
-export function DocsChat() {
-  const [open, setOpen] = useState(false);
+export function DocsChat({
+  defaultOpen = false,
+  defaultWidth = DESKTOP_DEFAULT_WIDTH,
+}: {
+  defaultOpen?: boolean;
+  defaultWidth?: number;
+}) {
+  const [open, setOpen] = useState(defaultOpen);
   const [input, setInput] = useState("");
-  const [focused, setFocused] = useState(false);
-  const messagesEndRef = useRef<HTMLDivElement>(null);
+  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 containerRef = useRef<HTMLDivElement>(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(() => {
@@ -157,77 +261,62 @@ export function DocsChat() {
     }
   }, [messages, isLoading]);
 
-  // Auto-open when new messages arrive (but not on initial restore)
-  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
+  // 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();
-        inputRef.current?.focus();
+        setOpen((prev) => {
+          if (!prev) {
+            setTimeout(() => inputRef.current?.focus(), 200);
+          }
+          return !prev;
+        });
       }
-      if (e.key === "Escape" && open) {
+      if (e.key === "Escape" && open && isDesktop) {
         setOpen(false);
-        inputRef.current?.blur();
       }
     };
     document.addEventListener("keydown", handleKeyDown);
     return () => document.removeEventListener("keydown", handleKeyDown);
-  }, [open]);
+  }, [open, isDesktop]);
 
-  // Close message area when clicking outside
+  // Auto-focus input when opened
   useEffect(() => {
-    if (!open) return;
-    const handleClickOutside = (e: MouseEvent) => {
-      if (
-        containerRef.current &&
-        !containerRef.current.contains(e.target as Node)
-      ) {
-        setOpen(false);
-      }
-    };
-    document.addEventListener("mousedown", handleClickOutside);
-    return () => document.removeEventListener("mousedown", handleClickOutside);
+    if (open) {
+      const timer = setTimeout(() => inputRef.current?.focus(), 200);
+      return () => clearTimeout(timer);
+    }
   }, [open]);
 
-  const handleSubmit = (e: React.FormEvent) => {
-    e.preventDefault();
-    if (!input.trim() || isLoading) return;
-    sendMessage({ text: input });
-    setInput("");
-  };
+  // 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 = () => {
+  const handleClear = useCallback(() => {
     setMessages([]);
     sessionStorage.removeItem(STORAGE_KEY);
-    setOpen(false);
-    inputRef.current?.focus();
-  };
+  }, [setMessages]);
 
   const hasVisibleContent = (
     parts: (typeof messages)[number]["parts"],
@@ -237,190 +326,31 @@ export function DocsChat() {
     );
   };
 
-  // Auto-open when error occurs
-  useEffect(() => {
-    if (error) setOpen(true);
-  }, [error]);
-
-  const showMessages = open && (messages.length > 0 || !!error || isLoading);
-  const showSuggestions = focused && messages.length === 0 && !isLoading;
-
-  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">
-                  json-render 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 */}
+  // 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 && (
-            <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">
-                  json-render Docs
-                </span>
-                <button
-                  onClick={handleClear}
-                  className="text-xs text-muted-foreground hover:text-foreground transition-colors"
-                  aria-label="Clear conversation"
-                >
-                  Clear
-                </button>
-              </div>
-              <div
-                className="p-4 space-y-4 overflow-y-auto"
-                onClick={(e) => {
-                  if ((e.target as HTMLElement).closest("a")) {
-                    setOpen(false);
-                  }
-                }}
-              >
-                {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 ref={messagesEndRef} />
-              </div>
-            </div>
+            <button
+              onClick={handleClear}
+              className="text-xs text-muted-foreground hover:text-foreground transition-colors"
+              aria-label="Clear conversation"
+            >
+              Clear
+            </button>
           )}
-
-          {/* Input bar */}
-          <form
-            onSubmit={handleSubmit}
-            onClick={() => inputRef.current?.focus()}
-            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
-              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}
-              onFocus={() => {
-                setFocused(true);
-                if (messages.length > 0) setOpen(true);
-              }}
-              onBlur={() => {
-                setFocused(false);
-              }}
-              onKeyDown={(e) => {
-                if (e.key === "Escape") {
-                  setOpen(false);
-                  inputRef.current?.blur();
-                }
-                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 relative z-10"
-            />
+          {isDesktop && (
             <button
-              type="submit"
-              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" : ""}`}
-              aria-label="Send message"
+              onClick={() => setOpen(false)}
+              className="text-muted-foreground hover:text-foreground transition-colors"
+              aria-label="Close panel"
             >
               <svg
-                width="16"
-                height="16"
+                width="14"
+                height="14"
                 viewBox="0 0 24 24"
                 fill="none"
                 stroke="currentColor"
@@ -428,13 +358,185 @@ export function DocsChat() {
                 strokeLinecap="round"
                 strokeLinejoin="round"
               >
-                <line x1="12" y1="19" x2="12" y2="5" />
-                <polyline points="5 12 12 5 19 12" />
+                <line x1="18" y1="6" x2="6" y2="18" />
+                <line x1="6" y1="6" x2="18" y2="18" />
               </svg>
             </button>
-          </form>
+          )}
         </div>
       </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>&#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"
+            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>
+      )}
+    </>
   );
 }

+ 14 - 5
apps/web/components/ui/sheet.tsx

@@ -18,7 +18,7 @@ const SheetOverlay = React.forwardRef<
 >(({ className, ...props }, ref) => (
   <SheetPrimitive.Overlay
     className={cn(
-      "fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:duration-150 data-[state=open]:duration-150",
+      "fixed inset-0 z-50 bg-black/75 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:duration-150 data-[state=open]:duration-150",
       className,
     )}
     {...props}
@@ -27,17 +27,26 @@ const SheetOverlay = React.forwardRef<
 ));
 SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
 
+const sheetSideVariants = {
+  left: "inset-y-0 left-0 h-full w-3/4 max-w-xs border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
+  right:
+    "inset-y-0 right-0 h-full w-3/4 max-w-xs data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
+};
+
 const SheetContent = React.forwardRef<
   React.ComponentRef<typeof SheetPrimitive.Content>,
-  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>
->(({ className, children, ...props }, ref) => (
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
+    side?: "left" | "right";
+    overlayClassName?: string;
+  }
+>(({ className, children, side = "left", overlayClassName, ...props }, ref) => (
   <SheetPortal>
-    <SheetOverlay />
+    <SheetOverlay className={overlayClassName} />
     <SheetPrimitive.Content
       ref={ref}
       className={cn(
         "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-150 data-[state=open]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out focus:outline-none",
-        "inset-y-0 left-0 h-full w-3/4 max-w-xs border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
+        sheetSideVariants[side],
         className,
       )}
       {...props}