diff --git a/packages/ui/app/c/[chatId]/page.tsx b/packages/ui/app/(app)/c/[chatId]/page.tsx similarity index 100% rename from packages/ui/app/c/[chatId]/page.tsx rename to packages/ui/app/(app)/c/[chatId]/page.tsx diff --git a/packages/ui/app/(app)/chat/page.tsx b/packages/ui/app/(app)/chat/page.tsx new file mode 100644 index 0000000..4c01c79 --- /dev/null +++ b/packages/ui/app/(app)/chat/page.tsx @@ -0,0 +1,24 @@ +import ChatWindow from '@/components/ChatWindow'; +import { Metadata } from 'next'; +import { Suspense } from 'react'; + +export const metadata: Metadata = { + title: 'Chat - Ask Starknet', + description: 'AI-powered assistant for Starknet and Cairo.', + icons: { + icon: '/ask_logo_white_alpha.png', + }, +}; + +const ChatPage = () => { + return ( +
+ + + +
+ ); +}; + +export default ChatPage; + diff --git a/packages/ui/app/(app)/history/page.tsx b/packages/ui/app/(app)/history/page.tsx new file mode 100644 index 0000000..9e0b2d9 --- /dev/null +++ b/packages/ui/app/(app)/history/page.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import DeleteChat from '@/components/DeleteChat'; +import { StoredChat } from '@/components/ChatWindow'; +import { trackHistoryPageViewed, trackHistoryItemClicked } from '@/lib/posthog'; +import { v4 as uuidv4 } from 'uuid'; +import { Search, X } from 'lucide-react'; + +const ChatHistory = () => { + const [chats, setChats] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [sessionId] = useState(() => uuidv4()); + const [isSearchExpanded, setIsSearchExpanded] = useState(false); + const [isClearAllHovered, setIsClearAllHovered] = useState(false); + + useEffect(() => { + if (typeof window !== 'undefined') { + const storedChats = JSON.parse(localStorage.getItem('chats') || '[]'); + setChats(storedChats); + + // Track history page view + trackHistoryPageViewed(sessionId); + } + }, [sessionId]); + + // Sort chats so the most recent (by chat.createdAt) comes first. + const sortedChats = [...chats].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + // Filter chats based on search term. + const filteredChats = sortedChats.filter((chat) => + (chat.title || `Chat ${chat.id}`) + .toLowerCase() + .includes(searchTerm.toLowerCase()), + ); + + // Handle chat item click + const handleChatClick = (chatId: string) => { + trackHistoryItemClicked(chatId); + }; + + return ( +
+
+
+

Chat History

+
+ {/* Search icon that expands to input on hover */} +
setIsSearchExpanded(true)} + onMouseLeave={() => { + if (searchTerm === '') { + setIsSearchExpanded(false); + } + }} + > + {(isSearchExpanded || searchTerm) && ( + + )} + setSearchTerm(e.target.value)} + className={`transition-all duration-300 ease-in-out pl-9 pr-3 py-1.5 rounded-md bg-light-secondary dark:bg-dark-secondary focus:outline-none focus:ring-0 border-0 ${ + isSearchExpanded || searchTerm + ? 'w-64 opacity-100' + : 'w-8 opacity-0' + }`} + style={{ + background: isSearchExpanded || searchTerm ? '' : 'transparent', + }} + /> + {!isSearchExpanded && !searchTerm && ( +
+
+ +
+
+ )} +
+ + {/* Clear All button as red X icon with text on hover */} +
setIsClearAllHovered(true)} + onMouseLeave={() => setIsClearAllHovered(false)} + > + + Clear All + + +
+
+
+ {filteredChats.length === 0 ? ( +

No chats found.

+ ) : ( +
    + {filteredChats.map((chat) => ( +
  • +
    + handleChatClick(chat.id)} + > + {chat.title || `Chat ${chat.id}`} + + {chat.createdAt && ( +

    + {new Date(chat.createdAt).toLocaleString()} +

    + )} +
    + +
  • + ))} +
+ )} +
+
+ ); +}; + +export default ChatHistory; diff --git a/packages/ui/app/(app)/layout.tsx b/packages/ui/app/(app)/layout.tsx new file mode 100644 index 0000000..6e7b87e --- /dev/null +++ b/packages/ui/app/(app)/layout.tsx @@ -0,0 +1,12 @@ +'use client'; + +import Sidebar from '@/components/Sidebar'; + +export default function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} + diff --git a/packages/ui/app/globals.css b/packages/ui/app/globals.css index db4bdcc..27c1fbb 100644 --- a/packages/ui/app/globals.css +++ b/packages/ui/app/globals.css @@ -15,13 +15,81 @@ body { position: relative; min-height: 100vh; - background-color: #000; /* Set a dark background color */ +} + +body.dark { + background-color: #000; +} + +body:not(.dark) { + background-color: #fcfcf9; } .content-wrapper { position: relative; z-index: 1; min-height: 100vh; + animation: fadeIn 0.8s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: scale(1.05); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes slideInLeft { + from { + opacity: 0; + transform: translateX(-100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.animate-slideInLeft { + animation: slideInLeft 0.6s ease-out; + animation-fill-mode: forwards; +} + +@keyframes slideInBottom { + from { + opacity: 0; + transform: translateY(100%); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-slideInBottom { + animation: slideInBottom 0.6s ease-out; +} + +@keyframes morphToMessage { + 0% { + opacity: 0; + transform: translateY(200px) scale(0.8); + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.animate-morphToMessage { + animation: morphToMessage 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; } @keyframes rotate { @@ -52,3 +120,120 @@ body { opacity: 0.2; } } + +@keyframes scroll-left { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-33.333%); + } +} + +@keyframes scroll-right { + 0% { + transform: translateX(-33.333%); + } + 100% { + transform: translateX(0); + } +} + +.animate-scroll-left { + animation: scroll-left 50s linear infinite; +} + +.animate-scroll-right { + animation: scroll-right 50s linear infinite; +} + +@keyframes float { + 0%, + 100% { + transform: translateY(0px) translateX(0px); + } + 25% { + transform: translateY(-10px) translateX(5px); + } + 50% { + transform: translateY(-5px) translateX(-5px); + } + 75% { + transform: translateY(-15px) translateX(3px); + } +} + +.animate-float { + animation: float 8s ease-in-out infinite; +} + +@keyframes float1 { + 0%, 100% { transform: translateY(0px) translateX(0px); } + 25% { transform: translateY(-15px) translateX(8px); } + 50% { transform: translateY(-8px) translateX(-6px); } + 75% { transform: translateY(-12px) translateX(4px); } +} + +@keyframes float2 { + 0%, 100% { transform: translateY(0px) translateX(0px); } + 25% { transform: translateY(-8px) translateX(-10px); } + 50% { transform: translateY(-18px) translateX(5px); } + 75% { transform: translateY(-5px) translateX(-8px); } +} + +@keyframes float3 { + 0%, 100% { transform: translateY(0px) translateX(0px); } + 25% { transform: translateY(-12px) translateX(-5px); } + 50% { transform: translateY(-6px) translateX(8px); } + 75% { transform: translateY(-16px) translateX(-3px); } +} + +@keyframes float4 { + 0%, 100% { transform: translateY(0px) translateX(0px); } + 25% { transform: translateY(-10px) translateX(10px); } + 50% { transform: translateY(-14px) translateX(-4px); } + 75% { transform: translateY(-7px) translateX(6px); } +} + +@keyframes float5 { + 0%, 100% { transform: translateY(0px) translateX(0px); } + 25% { transform: translateY(-9px) translateX(-7px); } + 50% { transform: translateY(-15px) translateX(9px); } + 75% { transform: translateY(-11px) translateX(-5px); } +} + +@keyframes float6 { + 0%, 100% { transform: translateY(0px) translateX(0px); } + 25% { transform: translateY(-13px) translateX(6px); } + 50% { transform: translateY(-7px) translateX(-9px); } + 75% { transform: translateY(-17px) translateX(2px); } +} + +.animate-float1 { animation: float1 8s ease-in-out infinite; } +.animate-float2 { animation: float2 8s ease-in-out infinite; } +.animate-float3 { animation: float3 8s ease-in-out infinite; } +.animate-float4 { animation: float4 8s ease-in-out infinite; } +.animate-float5 { animation: float5 8s ease-in-out infinite; } +.animate-float6 { animation: float6 8s ease-in-out infinite; } + +/* Mobile-specific responsive improvements */ +@media (max-width: 640px) { + body { + font-size: 14px; + } + + .content-wrapper { + padding-bottom: 60px; /* Space for bottom navigation */ + } +} + +/* Prevent horizontal scrolling on small screens */ +@media (max-width: 768px) { + body { + overflow-x: hidden; + } + + * { + -webkit-tap-highlight-color: transparent; + } +} diff --git a/packages/ui/app/history/page.tsx b/packages/ui/app/history/page.tsx deleted file mode 100644 index 496bedb..0000000 --- a/packages/ui/app/history/page.tsx +++ /dev/null @@ -1,99 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import DeleteChat from '@/components/DeleteChat'; -import { StoredChat } from '@/components/ChatWindow'; -import { trackHistoryPageViewed, trackHistoryItemClicked } from '@/lib/posthog'; -import { v4 as uuidv4 } from 'uuid'; - -const ChatHistory = () => { - const [chats, setChats] = useState([]); - const [searchTerm, setSearchTerm] = useState(''); - const [sessionId] = useState(() => uuidv4()); - - useEffect(() => { - if (typeof window !== 'undefined') { - const storedChats = JSON.parse(localStorage.getItem('chats') || '[]'); - setChats(storedChats); - - // Track history page view - trackHistoryPageViewed(sessionId); - } - }, [sessionId]); - - // Sort chats so the most recent (by chat.createdAt) comes first. - const sortedChats = [...chats].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); - - // Filter chats based on search term. - const filteredChats = sortedChats.filter((chat) => - (chat.title || `Chat ${chat.id}`) - .toLowerCase() - .includes(searchTerm.toLowerCase()), - ); - - // Handle chat item click - const handleChatClick = (chatId: string) => { - trackHistoryItemClicked(chatId); - }; - - return ( -
-
-

Chat History

-
- setSearchTerm(e.target.value)} - className="px-2 py-1 border border-light-200 dark:border-dark-200 rounded-md" - /> - -
-
- {filteredChats.length === 0 ? ( -

No chats found.

- ) : ( -
    - {filteredChats.map((chat) => ( -
  • -
    - handleChatClick(chat.id)} - > - {chat.title || `Chat ${chat.id}`} - - {chat.createdAt && ( -

    - {new Date(chat.createdAt).toLocaleString()} -

    - )} -
    - -
  • - ))} -
- )} -
- ); -}; - -export default ChatHistory; diff --git a/packages/ui/app/layout.tsx b/packages/ui/app/layout.tsx index f173af9..1bd5f20 100644 --- a/packages/ui/app/layout.tsx +++ b/packages/ui/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from 'next'; import { IBM_Plex_Sans } from 'next/font/google'; import './globals.css'; import { cn } from '@/lib/utils'; -import Sidebar from '@/components/Sidebar'; import { Toaster } from 'sonner'; import ThemeProvider from '@/components/theme/Provider'; import PostHogProviderClient from '@/components/providers/PostHogProvider'; @@ -17,10 +16,10 @@ const ibmPlexSans = IBM_Plex_Sans({ }); export const metadata: Metadata = { - title: 'The Starknet Agent - Unlock your Starknet expertise', + title: 'Ask Starknet - Unlock your Starknet expertise', description: 'AI-powered assistant for Starknet and Cairo.', icons: { - icon: '/starknet_logo.svg', + icon: '/favicon.ico', }, }; @@ -30,10 +29,9 @@ export default function RootLayout({ children: React.ReactNode; }>) { const config = { - loader: { load: ['input/asciimath'] }, - asciimath: { displaystyle: false }, + loader: { load: ['[tex]/boldsymbol', '[tex]/ams', '[tex]/html'] }, tex: { - packages: { '[+]': ['html'] }, + packages: { '[+]': ['boldsymbol', 'ams', 'html'] }, inlineMath: [ ['$', '$'], ['\\(', '\\)'], @@ -43,6 +41,15 @@ export default function RootLayout({ ['\\[', '\\]'], ], }, + svg: { fontCache: 'global' }, + options: { + skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code'], + ignoreHtmlClass: 'tex2jax_ignore', + processHtmlClass: 'tex2jax_process', + }, + startup: { + typeset: false, // Don't typeset on startup, let components control it + }, }; return ( @@ -51,7 +58,7 @@ export default function RootLayout({ - {children} + {children} { }, []); return loading ? ( -
- -
- ) : ( -
-
-
- -

- Library -

-
+ +
+
- {chats.length === 0 && ( -
-

- No chats found. -

+ + ) : ( + +
+
+
+ +

+ Library +

+
- )} - {chats.length > 0 && ( -
- {chats.map((chat, i) => ( -
- +

+ No chats found. +

+
+ )} + {chats.length > 0 && ( +
+ {chats.map((chat, i) => ( +
- {chat.title} - -
-
- -

- {formatTimeDifference(new Date(), chat.createdAt)} Ago -

+ + {chat.title} + +
+
+ +

+ {formatTimeDifference(new Date(), chat.createdAt)} Ago +

+
+
-
-
- ))} -
- )} -
+ ))} +
+ )} +
+
); }; diff --git a/packages/ui/app/page.tsx b/packages/ui/app/page.tsx index 848cd03..6126afd 100644 --- a/packages/ui/app/page.tsx +++ b/packages/ui/app/page.tsx @@ -1,26 +1,16 @@ -import ChatWindow from '@/components/ChatWindow'; -import { MathJaxContext } from 'better-react-mathjax'; +import LandingPage from '@/components/LandingPage'; import { Metadata } from 'next'; -import { Suspense } from 'react'; export const metadata: Metadata = { - title: 'The Starknet Agent - Unlock your Starknet expertise', + title: 'Ask Starknet - Unlock your Starknet expertise', description: 'AI-powered assistant for Starknet and Cairo.', icons: { - icon: '/starknet_logo.svg', + icon: '/ask_logo_white_alpha.png', }, }; const Home = () => { - return ( - <> -
- - - -
- - ); + return ; }; export default Home; diff --git a/packages/ui/components/Chat.tsx b/packages/ui/components/Chat.tsx index 2b3a0b9..5ba303a 100644 --- a/packages/ui/components/Chat.tsx +++ b/packages/ui/components/Chat.tsx @@ -45,8 +45,8 @@ const Chat = ({ return (
-
-
+
+
{messages.map((msg, i) => { const isLast = i === messages.length - 1; return ( @@ -68,8 +68,8 @@ const Chat = ({
-
-
+
+
diff --git a/packages/ui/components/ChatWindow.tsx b/packages/ui/components/ChatWindow.tsx index f1cf391..7926f78 100644 --- a/packages/ui/components/ChatWindow.tsx +++ b/packages/ui/components/ChatWindow.tsx @@ -1,21 +1,23 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Document } from '@langchain/core/documents'; +import { useEffect, useRef, useState } from 'react'; +import dynamic from 'next/dynamic'; +import type { Document } from '@langchain/core/documents'; import Navbar from './Navbar'; import Chat from './Chat'; -import EmptyChat from './EmptyChat'; import crypto from 'crypto'; import { toast } from 'sonner'; import { useSearchParams } from 'next/navigation'; import { getSuggestions } from '@/lib/actions'; -import { MathJaxContext } from 'better-react-mathjax'; import { trackConversationStart, trackUserMessage, initUserFeedbackStats, } from '@/lib/posthog'; +// Only lazy load EmptyChat as it doesn't use MathJax +const EmptyChat = dynamic(() => import('./EmptyChat'), { ssr: false }); + export type Message = { messageId: string; chatId: string; @@ -109,25 +111,50 @@ const loadMessagesFromLocalStorage = ( return null; }; -// MathJax configuration -const mathJaxConfig = { - loader: { load: ['[tex]/html'] }, - tex: { - packages: { '[+]': ['html'] }, - inlineMath: [ - ['$', '$'], - ['\\(', '\\)'], - ], - displayMath: [ - ['$$', '$$'], - ['\\[', '\\]'], - ], - }, +// Helper function to determine focus mode from hints parameter +const getFocusModeFromHints = (hints: string | null): string => { + if (!hints) return 'starknetEcosystemSearch'; + + // If hints is already a valid focus mode, return it directly + const validFocusModes = [ + 'starknetEcosystemSearch', + 'cairoBook', + 'starknetDocumentation', + 'starknetJS', + 'webSearch', + ]; + + if (validFocusModes.includes(hints)) { + return hints; + } + + // Map hints to focus modes + const hintsMap: Record = { + 'cairo': 'cairoBook', + 'starknet': 'starknetEcosystemSearch', + 'ecosystem': 'starknetEcosystemSearch', + 'docs': 'starknetDocumentation', + 'js': 'starknetJS', + 'search': 'webSearch', + }; + + return hintsMap[hints.toLowerCase()] || 'starknetEcosystemSearch'; }; -const ChatWindow = ({ id }: { id?: string }) => { +const ChatWindow = ({ + id, + initialMessage: initialMessageProp, + focusMode: focusModeProp, + onBack, +}: { + id?: string; + initialMessage?: string; + focusMode?: string; + onBack?: () => void; +}) => { const searchParams = useSearchParams(); - const initialMessage = searchParams.get('prompt') || searchParams.get('q'); + const initialMessage = initialMessageProp || searchParams.get('prompt') || searchParams.get('q'); + const hintsParam = searchParams.get('hints'); const [chatId, setChatId] = useState(id); const [newChatCreated, setNewChatCreated] = useState(false); @@ -138,14 +165,14 @@ const ChatWindow = ({ id }: { id?: string }) => { const [isApiReady, setIsApiReady] = useState(false); useApiReady(setIsApiReady, setHasError); - const FOCUS_MODE = 'starknetEcosystemSearch'; - const [loading, setLoading] = useState(false); const [messageAppeared, setMessageAppeared] = useState(false); const [chatHistory, setChatHistory] = useState<[string, string][]>([]); const [messages, setMessages] = useState([]); + const [focusMode, setFocusMode] = useState(focusModeProp || getFocusModeFromHints(hintsParam)); + const [isMessagesLoaded, setIsMessagesLoaded] = useState(false); useEffect(() => { @@ -156,11 +183,14 @@ const ChatWindow = ({ id }: { id?: string }) => { messages.length === 0 ) { const storedMessages = loadMessagesFromLocalStorage(chatId); - setMessages(storedMessages?.messages || []); - const history = storedMessages?.messages.map((msg) => { - return [msg.role, msg.content]; - }) as [string, string][]; - setChatHistory(history); + if (storedMessages) { + setMessages(storedMessages.messages); + setFocusMode(storedMessages.focusMode); + const history = storedMessages.messages.map((msg) => { + return [msg.role, msg.content]; + }) as [string, string][]; + setChatHistory(history); + } setIsMessagesLoaded(true); } else if (!chatId) { setNewChatCreated(true); @@ -179,11 +209,11 @@ const ChatWindow = ({ id }: { id?: string }) => { if (isMessagesLoaded && isApiReady && chatId) { // Track conversation start if this is a new chat if (newChatCreated) { - trackConversationStart(FOCUS_MODE, chatId); + trackConversationStart(focusMode, chatId); } setIsReady(true); } - }, [isMessagesLoaded, isApiReady, chatId, newChatCreated, FOCUS_MODE]); + }, [isMessagesLoaded, isApiReady, chatId, newChatCreated, focusMode]); const messagesRef = useRef([]); @@ -422,7 +452,7 @@ const ChatWindow = ({ id }: { id?: string }) => { saveMessagesToLocalStorage( chatId!, [...messages, humanMessage, assistantMessage], - FOCUS_MODE, + focusMode, ); const lastMsg = messagesRef.current[messagesRef.current.length - 1]; @@ -483,15 +513,13 @@ const ChatWindow = ({ id }: { id?: string }) => { {messages.length > 0 ? ( <> - - - + ) : ( @@ -518,4 +546,11 @@ const ChatWindow = ({ id }: { id?: string }) => {
); }; +export type ChatWindowProps = { + id?: string; + initialMessage?: string; + focusMode?: string; + onBack?: () => void; +}; + export default ChatWindow; diff --git a/packages/ui/components/EmptyChat.tsx b/packages/ui/components/EmptyChat.tsx index ca28917..086b526 100644 --- a/packages/ui/components/EmptyChat.tsx +++ b/packages/ui/components/EmptyChat.tsx @@ -7,10 +7,9 @@ const EmptyChat = ({ }) => { return (
-
-
-

Welcome to the Starknet Agent

-

+
+
+

Unlock your Starknet expertise.

diff --git a/packages/ui/components/EmptyChatMessageInput.tsx b/packages/ui/components/EmptyChatMessageInput.tsx index 33e248d..2c64033 100644 --- a/packages/ui/components/EmptyChatMessageInput.tsx +++ b/packages/ui/components/EmptyChatMessageInput.tsx @@ -1,15 +1,14 @@ -import { ArrowRight } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import TextareaAutosize from 'react-textarea-autosize'; -import CopilotToggle from './MessageInputActions/Copilot'; +import Focus from './MessageInputActions/Focus'; const EmptyChatMessageInput = ({ sendMessage, }: { sendMessage: (message: string) => void; }) => { - const [copilotEnabled, setCopilotEnabled] = useState(false); const [message, setMessage] = useState(''); + const [focusMode, setFocusMode] = useState('starknetEcosystemSearch'); const inputRef = useRef(null); @@ -44,39 +43,38 @@ const EmptyChatMessageInput = ({ }} className="w-full" > -
- + setMessage(e.target.value)} - minRows={2} - className="bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48" placeholder="Ask anything..." + className="bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50 text-base sm:text-lg text-black dark:text-white resize-none focus:outline-none w-full py-2 sm:py-3" /> -
-
- {/* */} -
- {/* TODO: disable copilot */} - {}} - className="opacity-50 cursor-not-allowed" - /> - - Coming Soon... - -
- +
+
+
+
diff --git a/packages/ui/components/LandingPage.tsx b/packages/ui/components/LandingPage.tsx new file mode 100644 index 0000000..f5f291b --- /dev/null +++ b/packages/ui/components/LandingPage.tsx @@ -0,0 +1,587 @@ +'use client'; + +import { useState, useEffect, Fragment } from 'react'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import Focus from './MessageInputActions/Focus'; +import { ArrowRight, ChevronDown, Copy, Check } from 'lucide-react'; +import { Popover, Transition } from '@headlessui/react'; +import { useTheme } from 'next-themes'; +import { + MCP_CLIENTS, + generateMCPDeepLink, + copyToClipboard, + openDeepLink, + type MCPStdioConfig, +} from '@/lib/mcpDeepLink'; + +type TabType = 'auto' | 'json'; + +const LandingPage = () => { + const router = useRouter(); + const { theme } = useTheme(); + const [prompt, setPrompt] = useState(''); + const [isTransitioning, setIsTransitioning] = useState(false); + const [focusMode, setFocusMode] = useState('starknetEcosystemSearch'); + const [showMCPConfig, setShowMCPConfig] = useState(false); + const [activeTab, setActiveTab] = useState('auto'); + const [selectedClient, setSelectedClient] = useState('cursor'); + const [copied, setCopied] = useState(false); + const [isMobile, setIsMobile] = useState(false); + + // Detect mobile on mount and resize + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 640); + }; + + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + // Calculate padding top based on screen size and config state + const getPaddingTop = () => { + if (showMCPConfig) { + return activeTab === 'json' + ? 'max(20vh, 100px)' + : 'max(25vh, 120px)'; + } + return isMobile ? 'max(25vh, 120px)' : 'calc(50vh - 100px)'; + }; + + // MCP Configuration for Ask Starknet + const mcpConfig: MCPStdioConfig = { + type: 'stdio', + command: 'npx', + args: ['-y', '@kasarlabs/ask-starknet-mcp'], + env: { + STARKNET_PUBLIC_ADDRESS: 'your-public-address-here', + STARKNET_PRIVATE_KEY: 'your-private-key-here', + STARKNET_RPC_URL: 'your-rpc-url-here', + MODEL_API_KEY: 'your-model-api-key-here', + }, + }; + + const displayName = 'Ask Starknet MCP'; + const selectedClientInfo = MCP_CLIENTS.find((c) => c.id === selectedClient); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (prompt.trim()) { + setIsTransitioning(true); + // Navigate to chat route with prompt as query parameter + setTimeout(() => { + router.push(`/chat?q=${encodeURIComponent(prompt)}&hints=${focusMode}`); + }, 150); + } + }; + + const handleChatClick = () => { + // Trigger fade out animation before navigation + setIsTransitioning(true); + // Navigate to chat route after a short delay for smooth transition + setTimeout(() => { + router.push('/chat'); + }, 150); + }; + + const handleMCPClick = () => { + setShowMCPConfig(!showMCPConfig); + }; + + const handleCopyConfig = async () => { + const configJson = JSON.stringify({ 'ask-starknet': mcpConfig }, null, 2); + const success = await copyToClipboard(configJson); + if (success) { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleOneClickSetup = () => { + try { + const deepLink = generateMCPDeepLink(selectedClient, displayName, mcpConfig, false); + openDeepLink(deepLink); + } catch (err) { + console.error('Failed to open deep link:', err); + } + }; + + const handleLogoClick = () => { + // Reset all states to initial landing page view + setShowMCPConfig(false); + setPrompt(''); + setActiveTab('auto'); + + // Scroll to top smoothly + window.scrollTo({ top: 0, behavior: 'smooth' }); + + // Navigate to home if not already there + if (window.location.pathname !== '/') { + router.push('/'); + } + }; + + return ( +
+ {/* Landing Page */} +
+ {/* Header with Logo */} +
+
+ {/* Logo - responsive size */} + +
+
+ + {/* Action Buttons - top right - fade out smoothly */} +
+ + + +
+ + {/* Landing Content - fades during transition */} +
+ {/* Floating Icons around the center - hidden on mobile */} +
+ +
+ + {/* Centered Title and Input - title fixed, content grows below */} +
+ {/* Title */} +
+

+ {showMCPConfig + ? 'Build your own Starknet Agents' + : 'Unlock your Starknet expertise.'} +

+ {showMCPConfig && ( +

+ Ask Starknet is available as a sophisticated MCP server. Access hundreds of Starknet tools and agents via a single ask_starknet method. +

+ )} +
+ + {/* Search Input / MCP Config - with growing transition */} +
+ {/* Container that grows */} +
+ {/* Search Input Content - slides left */} +
+
+ setPrompt(e.target.value)} + placeholder="Ask anything..." + className="bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50 text-base sm:text-lg text-black dark:text-white resize-none focus:outline-none w-full py-2 sm:py-3" + autoFocus={!showMCPConfig} + disabled={isTransitioning} + /> +
+
+ +
+ +
+
+
+ + {/* MCP Config Content - appears while container grows */} +
+ {showMCPConfig && ( +
+ {/* Tabs */} +
+
+ + +
+
+ + {/* Content Container with relative positioning for absolute content */} +
+ {/* JSON Tab Content */} +
+
+

+ Add this configuration to any MCP client settings. +

+ + {/* JSON Config */} +
+ +
+
+                                  
+                                    
+                                      "ask-starknet"
+                                    
+                                    : {'{'}
+                                    {'\n  '}
+                                    "command"
+                                    : 
+                                    
+                                      "{mcpConfig.command}"
+                                    
+                                    ,
+                                    {'\n  '}
+                                    "args"
+                                    : [
+                                    {mcpConfig.args.map((arg, i) => (
+                                      
+                                        {'\n    '}
+                                        "{arg}"
+                                        {i < mcpConfig.args.length - 1 && (
+                                          ,
+                                        )}
+                                      
+                                    ))}
+                                    {'\n  '}
+                                    ],
+                                    {'\n  '}
+                                    "env"
+                                    : {'{'}
+                                    {mcpConfig.env &&
+                                      Object.entries(mcpConfig.env).map(([key, value], i, arr) => (
+                                        
+                                          {'\n    '}
+                                          
+                                            "{key}"
+                                          
+                                          : 
+                                          
+                                            "{value}"
+                                          
+                                          {i < arr.length - 1 && (
+                                            ,
+                                          )}
+                                        
+                                      ))}
+                                    {'\n  '}
+                                    {'}'}
+                                    {'\n'}
+                                    {'}'}
+                                  
+                                
+
+
+
+
+ + {/* Auto Tab Content */} +
+
+ {/* Client Dropdown and One-Click Install */} +
+

+ Connect this server to {selectedClientInfo?.name} with one click. +

+
+ + {({ open }) => ( + <> + +
+ {selectedClientInfo?.icon && ( +
+ {selectedClientInfo.name} +
+ )} + + {selectedClientInfo?.name} + +
+ +
+ + + +
+ {MCP_CLIENTS.filter((client) => client.id !== selectedClient).map((client) => ( + setSelectedClient(client.id)} + className="w-full flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-2.5 sm:py-3 hover:bg-light-200 dark:hover:bg-dark-200 transition-colors" + > +
+ {client.name} +
+ + {client.name} + +
+ ))} +
+
+
+ + )} +
+ + {/* One-Click Install Icon */} + +
+
+
+
+
+
+ )} +
+
+
+
+
+ +
+
+ ); +}; + +const FloatingIcons = ({ isAnimating }: { isAnimating?: boolean }) => { + const icons = [ + { id: 1, image: 'https://pbs.twimg.com/profile_images/1876581196173320192/pF4KQQCb_400x400.jpg', twitter: 'extendedapp', x: '15%', y: '20%', blur: 6, size: 82, mobileSize: 60, delay: 0, floatAnim: 'animate-float1' }, + { id: 2, image: 'https://pbs.twimg.com/profile_images/1024585501901303808/m92jEcPI_400x400.jpg', twitter: 'ready_co', x: '75%', y: '15%', blur: 7, size: 95, mobileSize: 70, delay: 0.2, floatAnim: 'animate-float2' }, + { id: 3, image: 'https://pbs.twimg.com/profile_images/1736767433635975168/G1H8l7Ci_400x400.jpg', twitter: 'avnu_fi', x: '85%', y: '45%', blur: 8, size: 78, mobileSize: 56, delay: 0.4, floatAnim: 'animate-float3' }, + { id: 4, image: 'https://pbs.twimg.com/profile_images/1846554119777013760/FydsgAUR_400x400.jpg', twitter: 'myBraavos', x: '20%', y: '70%', blur: 6, size: 100, mobileSize: 72, delay: 0.1, floatAnim: 'animate-float4' }, + { id: 5, image: 'https://pbs.twimg.com/profile_images/1872475547059834880/TGT0jlCk_400x400.jpg', twitter: 'XverseApp', x: '80%', y: '75%', blur: 9, size: 75, mobileSize: 54, delay: 0.3, floatAnim: 'animate-float5' }, + { id: 6, image: 'https://pbs.twimg.com/profile_images/1899459698551562240/_WK4Lfeb_400x400.jpg', twitter: 'vesuxyz', x: '10%', y: '45%', blur: 7, size: 88, mobileSize: 64, delay: 0.5, floatAnim: 'animate-float6' }, + { id: 7, image: 'https://pbs.twimg.com/profile_images/1676963409303322624/NuCcNNxa_400x400.png', twitter: 'EkuboProtocol', x: '65%', y: '85%', blur: 8, size: 92, mobileSize: 66, delay: 0.2, floatAnim: 'animate-float1' }, + { id: 8, image: 'https://pbs.twimg.com/profile_images/1782677936585256960/JAwtVCsD_400x400.png', twitter: 'cairolang', x: '30%', y: '12%', blur: 6, size: 80, mobileSize: 58, delay: 0.4, floatAnim: 'animate-float2' }, + { id: 9, image: 'https://pbs.twimg.com/profile_images/1845153042762436629/LZs7_I2b_400x400.jpg', twitter: 'cartridge_gg', x: '92%', y: '60%', blur: 7, size: 98, mobileSize: 70, delay: 0.1, floatAnim: 'animate-float3' }, + { id: 10, image: 'https://pbs.twimg.com/profile_images/1845152900256829447/H6PRbeYs_400x400.jpg', twitter: 'ohayo_dojo', x: '5%', y: '85%', blur: 9, size: 76, mobileSize: 55, delay: 0.3, floatAnim: 'animate-float4' }, + { id: 11, image: 'https://pbs.twimg.com/profile_images/1854492998954012672/wcFszeR-_400x400.jpg', twitter: 'endurfi', x: '50%', y: '30%', blur: 6, size: 85, mobileSize: 62, delay: 0.15, floatAnim: 'animate-float5' }, + { id: 12, image: 'https://pbs.twimg.com/profile_images/1635993072327639041/G_YIQ-G1_400x400.jpg', twitter: 'layerswap', x: '60%', y: '25%', blur: 7, size: 90, mobileSize: 65, delay: 0.35, floatAnim: 'animate-float6' }, + { id: 13, image: 'https://pbs.twimg.com/profile_images/1940437227642798080/EnotVJl3_400x400.jpg', twitter: 'tradeparadex', x: '25%', y: '40%', blur: 8, size: 83, mobileSize: 60, delay: 0.45, floatAnim: 'animate-float1' }, + { id: 14, image: 'https://pbs.twimg.com/profile_images/1686699616853454848/GMEuUL8M_400x400.jpg', twitter: 'FocusTree_', x: '40%', y: '55%', blur: 10, size: 78, mobileSize: 56, delay: 0.25, floatAnim: 'animate-float2' }, + ]; + + return ( + + ); +}; + +export default LandingPage; + diff --git a/packages/ui/components/Layout.tsx b/packages/ui/components/Layout.tsx index 00f0fff..b1f99df 100644 --- a/packages/ui/components/Layout.tsx +++ b/packages/ui/components/Layout.tsx @@ -1,7 +1,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => { return (
-
{children}
+
{children}
); }; diff --git a/packages/ui/components/MessageBox.tsx b/packages/ui/components/MessageBox.tsx index 8384cc7..c9f6161 100644 --- a/packages/ui/components/MessageBox.tsx +++ b/packages/ui/components/MessageBox.tsx @@ -20,22 +20,20 @@ import MessageSources from './MessageSources'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { trackFeedback } from '@/lib/posthog'; -import { Document } from '@langchain/core/documents'; +import type { Document } from '@langchain/core/documents'; import { MathJax, - MathJaxContext, - MathJaxBaseContext, } from 'better-react-mathjax'; // Common styling patterns (unchanged) const styles = { messageBubble: { - base: 'rounded-2xl px-3 sm:px-4 py-2', - user: 'bg-gray-100 dark:bg-gray-800 text-black dark:text-white', - assistant: 'bg-gray-100 dark:bg-gray-800 text-black dark:text-white', + base: 'rounded-2xl px-2 sm:px-3 md:px-4 py-2', + user: 'text-black dark:text-white', + assistant: 'text-black dark:text-white', }, inlineCode: { - base: 'px-1 sm:px-1.5 py-0.5 rounded-md font-mono text-[0.85em] sm:text-[0.9em] break-words whitespace-normal', + base: 'px-1 sm:px-1.5 py-0.5 rounded-md font-mono text-[0.8em] sm:text-[0.85em] md:text-[0.9em] break-words whitespace-normal', user: 'bg-grey-100 dark:bg-gray-700/50 text-gray-800 dark:text-gray-200', assistant: 'bg-grey-100 dark:bg-gray-700/50 text-gray-800 dark:text-gray-200', @@ -43,72 +41,72 @@ const styles = { codeBlock: { base: 'relative group rounded-lg overflow-hidden', header: - 'absolute top-0 left-0 right-0 h-7 sm:h-8 bg-gray-800/50 dark:bg-gray-800/30 backdrop-blur-sm border-b border-gray-700/20', + 'absolute top-0 left-0 right-0 h-6 sm:h-7 md:h-8 bg-gray-800/50 dark:bg-gray-800/30 backdrop-blur-sm border-b border-gray-700/20', background: 'bg-[#1E1E1E]', border: 'border border-gray-800', - padding: 'px-3 sm:px-4 py-2 sm:py-3', - fontSize: 'text-[13px] sm:text-sm', - wrapper: 'overflow-x-auto whitespace-pre-wrap break-words mt-3 sm:mt-5', + padding: 'px-2 sm:px-3 md:px-4 py-2 sm:py-2.5 md:py-3', + fontSize: 'text-xs sm:text-[13px] md:text-sm', + wrapper: 'overflow-x-auto whitespace-pre-wrap break-words mt-2 sm:mt-3 md:mt-5', }, copyButton: { base: cn( - 'absolute right-1 sm:right-2 top-1 sm:top-2 p-1 sm:p-1.5 rounded-md bg-gray-700/50 backdrop-blur-sm', - 'opacity-0 group-hover:opacity-100 transition-opacity duration-150', - 'hover:bg-gray-700/70', + 'absolute right-1 sm:right-1.5 md:right-2 top-1 sm:top-1.5 md:top-2 p-1 sm:p-1.5 rounded-md bg-gray-700/50 backdrop-blur-sm', + 'opacity-0 group-hover:opacity-100', + 'hover:scale-110 transition-all duration-150', ), }, avatar: { - base: 'flex-shrink-0 w-6 h-6 sm:w-8 sm:h-8 rounded-full flex items-center justify-center', + base: 'flex-shrink-0 w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 lg:w-8 lg:h-8 rounded-full flex items-center justify-center', assistant: 'bg-blue-100 dark:bg-blue-900', assistantIcon: 'text-blue-600 dark:text-blue-300', user: 'bg-blue-600', userIcon: 'text-white', }, messageContainer: { - base: 'flex flex-col space-y-1.5 sm:space-y-2', - maxWidth: 'max-w-[90%] sm:max-w-[85%] md:max-w-[80%]', + base: 'flex flex-col space-y-1 sm:space-y-1.5 md:space-y-2', + maxWidth: 'max-w-[92%] sm:max-w-[88%] md:max-w-[85%] lg:max-w-[80%]', user: 'items-end', assistant: 'items-start', }, prose: { base: cn( 'prose dark:prose-invert prose-p:leading-relaxed prose-pre:p-0', - 'max-w-none break-words text-sm sm:text-base', + 'max-w-none break-words text-xs sm:text-sm md:text-base', 'prose-pre:overflow-x-auto prose-pre:scrollbar-thin prose-pre:scrollbar-thumb-gray-400 prose-pre:scrollbar-track-gray-200', 'dark:prose-pre:scrollbar-thumb-gray-600 dark:prose-pre:scrollbar-track-gray-800', ), - user: 'prose-headings:text-white prose-p:text-grey dark:prose-headings:text-white dark:prose-p:text-white', + user: 'prose-headings:text-black dark:prose-headings:text-white prose-p:text-black dark:prose-p:text-white', }, sources: { container: 'mt-2 transition-all', header: cn( - 'flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400', + 'flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-gray-500 dark:text-gray-400', 'hover:text-gray-700 dark:hover:text-gray-200 transition-colors', 'cursor-pointer select-none', ), - content: 'mt-2 pl-6 border-l-2 border-gray-200 dark:border-gray-700', - icon: 'w-4 h-4 rotate-180 transition-transform duration-200', + content: 'mt-2 pl-4 sm:pl-6 border-l-2 border-gray-200 dark:border-gray-700', + icon: 'w-3 h-3 sm:w-4 sm:h-4 rotate-180 transition-transform duration-200', }, suggestions: { - container: 'mt-4 transition-all', + container: 'mt-3 sm:mt-4 transition-all', header: cn( - 'flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400', + 'flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-gray-500 dark:text-gray-400', 'hover:text-gray-700 dark:hover:text-gray-200 transition-colors', 'cursor-pointer select-none', ), content: 'mt-2 space-y-2', button: cn( - 'w-full text-left px-3 py-2 rounded-lg', + 'w-full text-left px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg', 'bg-gray-100 dark:bg-gray-800/50', 'hover:bg-gray-200 dark:hover:bg-gray-700/50', 'focus:outline-none focus:ring-2 focus:ring-blue-500/50', - 'transition-colors text-sm text-gray-700 dark:text-gray-300', + 'transition-colors text-xs sm:text-sm text-gray-700 dark:text-gray-300', ), }, actions: { container: 'flex items-center gap-1 mt-2', button: cn( - 'p-1.5 rounded-lg text-gray-500 dark:text-gray-400', + 'p-1 sm:p-1.5 rounded-lg text-gray-500 dark:text-gray-400', 'hover:bg-gray-100 dark:hover:bg-gray-800', 'focus:outline-none focus:ring-2 focus:ring-blue-500/50', 'transition-colors', @@ -160,8 +158,8 @@ const CodeBlock = ({
{language && language !== 'text' && (
-
- {language} +
+ {language}
)} @@ -175,7 +173,7 @@ const CodeBlock = ({
@@ -206,16 +204,37 @@ const CodeBlock = ({ const LatexRenderer = ({ isBlock = false, children, + isLoading = false, }: { isBlock?: boolean; children: string; // Expect a raw formula string + isLoading?: boolean; }) => { const formula = String(children || '').trim(); + const [renderError, setRenderError] = React.useState(false); if (!formula) { return null; } + // Don't render MathJax while content is still loading/streaming + if (isLoading) { + return ( + + {isBlock ? `$$${formula}$$` : `$${formula}$`} + + ); + } + + if (renderError) { + // Fallback: render the formula as inline code + return ( + + {isBlock ? `$$${formula}$$` : `$${formula}$`} + + ); + } + try { if (isBlock) { return ( @@ -235,7 +254,12 @@ const LatexRenderer = ({
- {`$$${formula}$$`} + setRenderError(true)} + hideUntilTypeset="every" + > + {`$$${formula}$$`} +
); @@ -250,7 +274,13 @@ const LatexRenderer = ({ className={cn(styles.latex.inline, 'cursor-pointer')} title="Click to copy formula" > - {`$${formula}$`} + setRenderError(true)} + hideUntilTypeset="every" + > + {`$${formula}$`} + ); } @@ -266,7 +296,7 @@ const LatexRenderer = ({ }; // Component to render text potentially mixed with inline LaTeX -const TextWithInlineMath = ({ text }: { text: string }) => { +const TextWithInlineMath = ({ text, isLoading = false }: { text: string; isLoading?: boolean }) => { if (typeof text !== 'string') { // Should not happen if called correctly, but good to be safe return <>{text}; @@ -284,7 +314,7 @@ const TextWithInlineMath = ({ text }: { text: string }) => { if (part.startsWith('$') && part.endsWith('$') && part.length > 2) { const formula = part.substring(1, part.length - 1); return ( - + {formula} ); @@ -299,10 +329,11 @@ const TextWithInlineMath = ({ text }: { text: string }) => { // Helper to recursively process children for inline math const renderChildrenWithInlineMath = ( children: React.ReactNode, + isLoading = false, ): React.ReactNode => { return React.Children.map(children, (child) => { if (typeof child === 'string') { - return ; + return ; } if (React.isValidElement(child) && child.props.children) { // Check if the element type should have its children processed @@ -316,7 +347,7 @@ const renderChildrenWithInlineMath = ( ) { return React.cloneElement(child, { ...child.props, - children: renderChildrenWithInlineMath(child.props.children), + children: renderChildrenWithInlineMath(child.props.children, isLoading), }); } } @@ -405,36 +436,38 @@ const MessageFeedback = ({ return (
-
-
+
+
Was this response helpful?
- - +
+ + +
{feedback && ( - + {feedback === 'positive' ? 'Thanks for your feedback!' : 'Thanks for your feedback.'} @@ -443,29 +476,29 @@ const MessageFeedback = ({
{showFeedbackModal && ( -
+
-

+

What was wrong with this response?