"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { cn } from "@turbostarter/ui"; import { Button } from "@turbostarter/ui-web/button"; import { Icons } from "@turbostarter/ui-web/icons"; import { ScrollArea } from "@turbostarter/ui-web/scroll-area"; import { api } from "~/lib/api/client"; import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown"; import { Prose } from "~/modules/common/prose"; import type { DiagramType } from "~/modules/diagram/types/graph"; interface CopilotPanelProps { diagramId: string; diagramType: DiagramType; } export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) { const chatId = useMemo(() => `copilot-${diagramId}`, [diagramId]); const [input, setInput] = useState(""); const scrollRef = useRef(null); const inputRef = useRef(null); const userScrolledRef = useRef(false); // Fetch existing chat history on mount (H1 fix) const { data: initialMessages } = useQuery({ queryKey: ["copilot", "messages", chatId], queryFn: async () => { const res = await api.ai.copilot.messages.$get({ query: { chatId }, }); if (!res.ok) return []; return res.json(); }, staleTime: Infinity, }); // Memoize transport to avoid recreation on every render (M2 fix) const transport = useMemo( () => new DefaultChatTransport({ api: api.ai.copilot.$url().toString(), prepareSendMessagesRequest: ({ messages, id }) => { const lastMessage = messages.at(-1); return { body: { ...lastMessage, chatId: id, diagramId, diagramType, }, }; }, }), [diagramId, diagramType], ); const { messages, sendMessage, status, error, stop, setMessages } = useChat({ id: chatId, transport, onError: (err) => { console.error("[copilot]", err); toast.error("Failed to get AI response"); }, }); // Seed chat with persisted history once loaded useEffect(() => { if (initialMessages && initialMessages.length > 0 && messages.length === 0) { setMessages( initialMessages.map((m) => ({ ...m, createdAt: new Date(), })), ); } }, [initialMessages, messages.length, setMessages]); const isSubmitting = status === "submitted" || status === "streaming"; // Auto-scroll on new content, but pause if user scrolled up useEffect(() => { if (userScrolledRef.current) return; const viewport = scrollRef.current?.querySelector( "[data-radix-scroll-area-viewport]", ); if (viewport) { viewport.scrollTop = viewport.scrollHeight; } }, [messages]); // Detect user scroll useEffect(() => { const viewport = scrollRef.current?.querySelector( "[data-radix-scroll-area-viewport]", ); if (!viewport) return; let timeoutId: NodeJS.Timeout; const handleScroll = () => { const isAtBottom = Math.abs( viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight, ) < 80; userScrolledRef.current = !isAtBottom; clearTimeout(timeoutId); timeoutId = setTimeout(() => { userScrolledRef.current = false; }, 2000); }; viewport.addEventListener("scroll", handleScroll); return () => { viewport.removeEventListener("scroll", handleScroll); clearTimeout(timeoutId); }; }, []); const handleSend = useCallback(() => { const text = input.trim(); if (!text || isSubmitting) return; void sendMessage({ text, metadata: {}, }); setInput(""); userScrolledRef.current = false; }, [input, isSubmitting, sendMessage]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend], ); return (
{/* Messages area */}
{messages.length === 0 && ( )} {messages.map((message) => (
{message.role === "user" ? ( ) : ( )}
))} {/* Typing indicator */} {status === "submitted" && (
)} {/* Error display */} {error && (
Something went wrong. Please try again.
)}
{/* Input area */}