import React, { useState, useCallback, useEffect } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useNavigate } from '@tanstack/react-router'; import { useChatContext } from './hooks/ChatContext'; import { useTheme } from './hooks/useTheme'; import { usePrompts } from './hooks/usePrompts'; import { useDeleteSession } from './hooks/useSessions'; import { client } from '@/lib/client'; import { useAddServer } from './hooks/useServers'; import { useResolvePrompt } from './hooks/usePrompts'; import { useChatStore, useCurrentSessionId, useIsWelcomeState, useAllMessages, useSessionProcessing, useSessionError, useCurrentToolName, } from '@/lib/stores'; import { useGreeting } from './hooks/useGreeting'; import MessageList from './MessageList'; import InputArea from './InputArea'; import ConnectServerModal from './ConnectServerModal'; import ServerRegistryModal from './ServerRegistryModal'; import ServersPanel from './ServersPanel'; import SessionPanel from './SessionPanel'; import MemoryPanel from './MemoryPanel'; import { ToolConfirmationHandler, type ApprovalEvent } from './ToolConfirmationHandler'; import GlobalSearchModal from './GlobalSearchModal'; import CustomizePanel from './AgentEditor/CustomizePanel'; import { Button } from './ui/button'; import { Server, Download, Wrench, Keyboard, AlertTriangle, MoreHorizontal, Menu, Trash2, Settings, ChevronDown, FlaskConical, Check, FileEditIcon, Brain, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose, } from './ui/dialog'; import { Label } from './ui/label'; import { Input } from './ui/input'; import { Textarea } from './ui/textarea'; import { Alert, AlertTitle, AlertDescription } from './ui/alert'; import { Badge } from './ui/badge'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, } from './ui/dropdown-menu'; import { SettingsPanel } from './settings/SettingsPanel'; import AgentSelector from './AgentSelector/AgentSelector'; import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip'; import { serverRegistry } from '@/lib/serverRegistry'; import { buildConfigFromRegistryEntry, hasEmptyOrPlaceholderValue } from '@/lib/serverConfig'; import type { McpServerConfig } from '@dexto/core'; import type { ServerRegistryEntry } from '@dexto/registry'; interface ChatAppProps { sessionId?: string; } export default function ChatApp({ sessionId }: ChatAppProps = {}) { const navigate = useNavigate(); // Get state from Zustand stores using centralized selectors const currentSessionId = useCurrentSessionId(); const isWelcomeState = useIsWelcomeState(); const messages = useAllMessages(currentSessionId); const processing = useSessionProcessing(currentSessionId); const activeError = useSessionError(currentSessionId); const currentToolName = useCurrentToolName(); // Get actions from ChatContext const { sendMessage, switchSession, returnToWelcome, cancel } = useChatContext(); // Get greeting from API const { greeting } = useGreeting(currentSessionId); // clearError now managed via chatStore const clearError = useCallback(() => { if (currentSessionId) { useChatStore.getState().setError(currentSessionId, null); } }, [currentSessionId]); // Theme management const { theme, toggleTheme } = useTheme(); // API mutations const { mutateAsync: addServer } = useAddServer(); const { mutateAsync: resolvePrompt } = useResolvePrompt(); const [isModalOpen, setModalOpen] = useState(false); const [isServerRegistryOpen, setServerRegistryOpen] = useState(false); const [isServersPanelOpen, setServersPanelOpen] = useState(false); const [isSessionsPanelOpen, setSessionsPanelOpen] = useState(false); const [isHydrated, setIsHydrated] = useState(false); const isFirstRenderRef = React.useRef(true); const [isSearchOpen, setSearchOpen] = useState(false); const [isExportOpen, setExportOpen] = useState(false); const [isSettingsOpen, setSettingsOpen] = useState(false); const [isCustomizePanelOpen, setCustomizePanelOpen] = useState(false); const [isMemoryPanelOpen, setMemoryPanelOpen] = useState(false); const [exportName, setExportName] = useState('dexto-config'); const [exportError, setExportError] = useState(null); const [exportContent, setExportContent] = useState(''); const [copySuccess, setCopySuccess] = useState(false); const [successMessage, setSuccessMessage] = useState(null); const [isMobileMenuOpen, setMobileMenuOpen] = useState(false); // Enhanced features const [isSendingMessage, setIsSendingMessage] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false); const [errorMessage, setErrorMessage] = useState(null); // Conversation management states const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); // Approval state (for inline rendering in message stream) const [pendingApproval, setPendingApproval] = useState(null); const [approvalHandlers, setApprovalHandlers] = useState<{ onApprove: (formData?: Record, rememberChoice?: boolean) => void; onDeny: () => void; } | null>(null); const deleteSessionMutation = useDeleteSession(); // Fetch starter prompts using shared usePrompts hook const { data: promptsData = [], isLoading: promptsLoading } = usePrompts({ enabled: isWelcomeState, }); // Filter prompts with showInStarters metadata flag const starterPrompts = promptsData.filter((prompt) => prompt.metadata?.showInStarters === true); const starterPromptsLoaded = !promptsLoading; // Note: Agent switch invalidation is now handled centrally in AgentSelector // Scroll management for robust autoscroll const scrollContainerRef = React.useRef(null); const listContentRef = React.useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); const [isScrollingToBottom, setIsScrollingToBottom] = useState(false); const [followStreaming, setFollowStreaming] = useState(false); const lastScrollTopRef = React.useRef(0); // Improved "Scroll to bottom" hint const [showScrollHint, setShowScrollHint] = useState(false); const scrollIdleTimerRef = React.useRef(null); // Server refresh trigger const [serversRefreshTrigger, setServersRefreshTrigger] = useState(0); // Prefill config for ConnectServerModal const [connectPrefill, setConnectPrefill] = useState<{ name: string; config: Partial & { type?: 'stdio' | 'sse' | 'http' }; lockName?: boolean; registryEntryId?: string; onCloseRegistryModal?: () => void; } | null>(null); const [isRegistryBusy, setIsRegistryBusy] = useState(false); useEffect(() => { const updateViewportHeight = () => { if (typeof document === 'undefined') return; const viewportHeight = window.visualViewport?.height ?? window.innerHeight; document.documentElement.style.setProperty( '--app-viewport-height', `${viewportHeight}px` ); }; updateViewportHeight(); window.addEventListener('resize', updateViewportHeight); window.addEventListener('orientationchange', updateViewportHeight); window.visualViewport?.addEventListener('resize', updateViewportHeight); return () => { window.removeEventListener('resize', updateViewportHeight); window.removeEventListener('orientationchange', updateViewportHeight); window.visualViewport?.removeEventListener('resize', updateViewportHeight); }; }, []); const recomputeIsAtBottom = useCallback(() => { const el = scrollContainerRef.current; if (!el) return; const nearBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 1; setIsAtBottom(nearBottom); }, []); const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => { const el = scrollContainerRef.current; if (!el) return; setIsScrollingToBottom(true); el.scrollTo({ top: el.scrollHeight, behavior }); // Release the lock on next frame to allow ResizeObserver to settle requestAnimationFrame(() => setIsScrollingToBottom(false)); }, []); // Observe user scroll position useEffect(() => { const el = scrollContainerRef.current; if (!el) return; const onScroll = () => { // When user scrolls up, disable followStreaming const prev = lastScrollTopRef.current; const curr = el.scrollTop; if (!isScrollingToBottom && followStreaming && curr < prev) { setFollowStreaming(false); } lastScrollTopRef.current = curr; recomputeIsAtBottom(); // Debounced hint: show when not at bottom after scrolling stops const nearBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 1; if (nearBottom) { setShowScrollHint(false); if (scrollIdleTimerRef.current) { window.clearTimeout(scrollIdleTimerRef.current); scrollIdleTimerRef.current = null; } } else { setShowScrollHint(false); if (scrollIdleTimerRef.current) window.clearTimeout(scrollIdleTimerRef.current); scrollIdleTimerRef.current = window.setTimeout(() => { setShowScrollHint(true); }, 180); } }; el.addEventListener('scroll', onScroll); // Initial compute in case of restored sessions recomputeIsAtBottom(); return () => el.removeEventListener('scroll', onScroll); }, [recomputeIsAtBottom, followStreaming, isScrollingToBottom, isWelcomeState]); // Content resize observer to autoscroll on content growth useEffect(() => { const content = listContentRef.current; if (!content) return; const ro = new ResizeObserver(() => { if (isScrollingToBottom) return; if (followStreaming || isAtBottom) scrollToBottom('auto'); }); ro.observe(content); return () => ro.disconnect(); }, [isAtBottom, isScrollingToBottom, followStreaming, scrollToBottom, isWelcomeState]); // Fallback: if messages change during streaming, ensure we keep following useEffect(() => { if (followStreaming) scrollToBottom('auto'); }, [followStreaming, messages, scrollToBottom]); // Position the last user message near the top then follow streaming const positionLastUserNearTop = useCallback(() => { const container = scrollContainerRef.current; if (!container) return; const nodes = container.querySelectorAll('[data-role="user"]'); const el = nodes[nodes.length - 1] as HTMLElement | undefined; if (!el) { // Fallback to bottom scrollToBottom('auto'); return; } const cRect = container.getBoundingClientRect(); const eRect = el.getBoundingClientRect(); const offsetTop = eRect.top - cRect.top + container.scrollTop; const target = Math.max(offsetTop - 16, 0); setIsScrollingToBottom(true); container.scrollTo({ top: target, behavior: 'auto' }); requestAnimationFrame(() => setIsScrollingToBottom(false)); }, [scrollToBottom]); useEffect(() => { if (isExportOpen) { // Fetch YAML configuration for preview const fetchConfig = async () => { try { const response = await client.api.agent.config.export.$get({ query: currentSessionId ? { sessionId: currentSessionId } : {}, }); if (!response.ok) { throw new Error('Failed to fetch configuration'); } const text = await response.text(); setExportContent(text); setExportError(null); } catch (err) { console.error('Preview fetch failed:', err); setExportError(err instanceof Error ? err.message : 'Preview fetch failed'); } }; void fetchConfig(); } else { setExportContent(''); setExportError(null); setCopySuccess(false); } }, [isExportOpen, currentSessionId]); const handleDownload = useCallback(async () => { try { const response = await client.api.agent.config.export.$get({ query: currentSessionId ? { sessionId: currentSessionId } : {}, }); if (!response.ok) { throw new Error('Failed to fetch configuration'); } const yamlText = await response.text(); const blob = new Blob([yamlText], { type: 'application/x-yaml' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; const fileName = currentSessionId ? `${exportName}-${currentSessionId}.yml` : `${exportName}.yml`; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } catch (error) { console.error('Download failed:', error); setExportError(error instanceof Error ? error.message : 'Download failed'); } }, [exportName, currentSessionId]); const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(exportContent); setCopySuccess(true); setTimeout(() => setCopySuccess(false), 2000); } catch (error) { console.error('Copy failed:', error); setExportError('Failed to copy to clipboard'); } }, [exportContent]); const handleSend = useCallback( async (content: string, imageData?: any, fileData?: any) => { setIsSendingMessage(true); setErrorMessage(null); try { await sendMessage(content, imageData, fileData); // After sending, position the new user message near the top, // then enable followStreaming to follow the assistant reply. setTimeout(() => { positionLastUserNearTop(); setFollowStreaming(true); }, 0); } catch (error) { console.error('Failed to send message:', error); setErrorMessage(error instanceof Error ? error.message : 'Failed to send message'); setTimeout(() => setErrorMessage(null), 5000); } finally { setIsSendingMessage(false); } }, [sendMessage, positionLastUserNearTop] ); // Toggle followStreaming based on processing state (replaces DOM events) useEffect(() => { setFollowStreaming(processing); }, [processing]); const handleSessionChange = useCallback( (sessionId: string) => { // Reset scroll state when changing sessions setFollowStreaming(false); setShowScrollHint(false); // Navigate to the session URL instead of just switching in context navigate({ to: `/chat/${sessionId}` }); // Keep the sessions panel open when switching sessions }, [navigate] ); const handleReturnToWelcome = useCallback(() => { // Reset scroll state when returning to welcome setFollowStreaming(false); setShowScrollHint(false); // Clear the context state first, then navigate to home page returnToWelcome(); navigate({ to: '/' }); }, [navigate, returnToWelcome]); // Handle hydration and restore localStorage state useEffect(() => { setIsHydrated(true); // Restore sessions panel state from localStorage after hydration const savedPanelState = localStorage.getItem('sessionsPanelOpen'); if (savedPanelState === 'true') { setSessionsPanelOpen(true); } // Mark first render as complete to enable transitions setTimeout(() => { isFirstRenderRef.current = false; }, 0); }, []); // Persist sessions panel state to localStorage useEffect(() => { if (isHydrated && typeof window !== 'undefined') { localStorage.setItem('sessionsPanelOpen', isSessionsPanelOpen.toString()); } }, [isSessionsPanelOpen, isHydrated]); // Handle sessionId prop from URL - for loading specific sessions useEffect(() => { if (sessionId && sessionId !== currentSessionId) { // Reset scroll state when switching sessions setFollowStreaming(false); setShowScrollHint(false); switchSession(sessionId); } }, [sessionId, currentSessionId, switchSession]); // Ensure welcome state on home page (when no sessionId prop) useEffect(() => { if (!sessionId && !isWelcomeState) { // We're on the home page but not in welcome state - reset to welcome returnToWelcome(); } }, [sessionId, isWelcomeState, returnToWelcome]); type InstallableRegistryEntry = ServerRegistryEntry & { onCloseRegistryModal?: () => void; }; const handleInstallServer = useCallback( async (entry: InstallableRegistryEntry): Promise<'connected' | 'requires-input'> => { const config = buildConfigFromRegistryEntry(entry); const needsEnvInput = config.type === 'stdio' && Object.keys(config.env || {}).length > 0 && hasEmptyOrPlaceholderValue(config.env || {}); const needsHeaderInput = (config.type === 'sse' || config.type === 'http') && 'headers' in config && Object.keys(config.headers || {}).length > 0 && hasEmptyOrPlaceholderValue(config.headers || {}); // If inputs needed, open modal but keep registry open if (needsEnvInput || needsHeaderInput) { setConnectPrefill({ name: entry.name, config, lockName: true, registryEntryId: entry.id, onCloseRegistryModal: entry.onCloseRegistryModal ?? (() => setServerRegistryOpen(false)), }); setModalOpen(true); return 'requires-input'; } try { setIsRegistryBusy(true); await addServer({ name: entry.name, config, persistToAgent: false, }); if (entry.id) { try { await serverRegistry.setInstalled(entry.id, true); } catch (e) { console.warn('Failed to mark registry entry installed:', e); } } setServersRefreshTrigger((prev) => prev + 1); setSuccessMessage(`Added ${entry.name}`); setTimeout(() => setSuccessMessage(null), 4000); setServerRegistryOpen(false); return 'connected'; } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to install server'; throw new Error(message); } finally { setIsRegistryBusy(false); } }, [ addServer, setServerRegistryOpen, setModalOpen, setConnectPrefill, setServersRefreshTrigger, setSuccessMessage, setIsRegistryBusy, ] ); // Helper to check if viewport is narrow (for panel exclusivity) const isNarrowViewport = () => { return typeof window !== 'undefined' && window.innerWidth < 768; }; // Smart panel handlers with exclusivity on narrow screens const handleOpenSessionsPanel = useCallback(() => { if (isNarrowViewport() && isServersPanelOpen) { setServersPanelOpen(false); // Close tools panel if open } setSessionsPanelOpen(!isSessionsPanelOpen); }, [isSessionsPanelOpen, isServersPanelOpen]); const handleOpenServersPanel = useCallback(() => { if (isNarrowViewport() && isSessionsPanelOpen) { setSessionsPanelOpen(false); // Close sessions panel if open } setServersPanelOpen(!isServersPanelOpen); }, [isServersPanelOpen, isSessionsPanelOpen]); const handleDeleteConversation = useCallback(async () => { if (!currentSessionId) return; try { await deleteSessionMutation.mutateAsync({ sessionId: currentSessionId }); setDeleteDialogOpen(false); handleReturnToWelcome(); } catch (error) { console.error('Failed to delete conversation:', error); setErrorMessage( error instanceof Error ? error.message : 'Failed to delete conversation' ); setTimeout(() => setErrorMessage(null), 5000); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentSessionId, handleReturnToWelcome]); // Memoize quick actions to prevent unnecessary recomputation const quickActions = React.useMemo( () => [ { title: 'Help me get started', description: 'Show me what you can do', action: () => handleSend( "I'm new to Dexto. Can you show me your capabilities and help me understand how to work with you effectively?" ), icon: '🚀', }, { title: 'Create Snake Game', description: 'Build a game and open it', action: () => handleSend( 'Create a snake game in a new directory with HTML, CSS, and JavaScript, then open it in the browser for me to play.' ), icon: '🐍', }, { title: 'Connect new tools', description: 'Browse and add MCP servers', action: () => setServersPanelOpen(true), icon: '🔧', }, { title: 'Demonstrate tools', description: 'Show me your capabilities', action: () => handleSend( 'Pick one of your most interesting tools and demonstrate it with a practical example. Show me what it can do.' ), icon: '⚡', }, ], [handleSend, setServersPanelOpen] ); // Merge dynamic quick actions from starter prompts const dynamicQuickActions = React.useMemo(() => { // Show default quick actions while loading if (!starterPromptsLoaded) { return quickActions.map((a) => ({ description: `${a.icon} ${a.title}`, tooltip: a.description, action: a.action, })); } // If starter prompts are present, hide the built-in defaults to avoid duplication const actions: Array<{ description: string; tooltip?: string; action: () => void }> = starterPrompts.length > 0 ? [] : quickActions.map((a) => ({ description: `${a.icon} ${a.title}`, tooltip: a.description, action: a.action, })); starterPrompts.forEach((prompt) => { const description = prompt.title || prompt.description || 'Starter prompt'; const tooltip = prompt.description; if (prompt?.name === 'starter:connect-tools') { actions.push({ description, tooltip, action: () => setServersPanelOpen(true), }); } else { // Starter prompts need to be resolved via API (metadata.prompt is stripped for security/performance) // This matches the resolution logic in InputArea for slash commands actions.push({ description, tooltip, action: async () => { try { // Resolve the prompt server-side just like InputArea does const result = await resolvePrompt({ name: prompt.name, }); if (result.text.trim()) { handleSend(result.text.trim()); } else { // Fallback: send slash command if resolution returned empty handleSend(`/${prompt.name}`); } } catch (error) { console.error( `Failed to resolve starter prompt ${prompt.name}:`, error ); // Fallback: send slash command on error handleSend(`/${prompt.name}`); } }, }); } }); return actions; }, [ starterPrompts, starterPromptsLoaded, quickActions, handleSend, setServersPanelOpen, resolvePrompt, ]); // Keyboard shortcuts (using react-hotkeys-hook) // Cmd/Ctrl + Backspace to delete current session useHotkeys( 'mod+backspace', () => { if (currentSessionId && !isWelcomeState) { // If session has messages, show confirmation dialog if (messages.length > 0) { setDeleteDialogOpen(true); } else { // No messages, delete immediately handleDeleteConversation(); } } }, { preventDefault: true }, [currentSessionId, isWelcomeState, messages.length, handleDeleteConversation] ); // Ctrl/Cmd + H to toggle sessions panel useHotkeys('mod+h', handleOpenSessionsPanel, { preventDefault: true }, [ handleOpenSessionsPanel, ]); // Ctrl/Cmd + K to create new chat (return to welcome) useHotkeys('mod+k', handleReturnToWelcome, { preventDefault: true }, [handleReturnToWelcome]); // Ctrl/Cmd + J to toggle tools/servers panel useHotkeys('mod+j', handleOpenServersPanel, { preventDefault: true }, [handleOpenServersPanel]); // Ctrl/Cmd + M to toggle memory panel useHotkeys('mod+m', () => setMemoryPanelOpen((prev) => !prev), { preventDefault: true }); // Ctrl/Cmd + Shift + S to open search useHotkeys('mod+shift+s', () => setSearchOpen(true), { preventDefault: true }); // Ctrl/Cmd + L to open MCP playground useHotkeys('mod+l', () => window.open('/playground', '_blank'), { preventDefault: true }); // Ctrl/Cmd + E to open customize panel useHotkeys('mod+e', () => setCustomizePanelOpen((prev) => !prev), { preventDefault: true }); // Ctrl/Cmd + Shift + E to export config useHotkeys('mod+shift+e', () => setExportOpen(true), { preventDefault: true }); // Ctrl/Cmd + / to show shortcuts useHotkeys('mod+slash', () => setShowShortcuts(true), { preventDefault: true }); // Escape to close panels or cancel run useHotkeys( 'escape', () => { if (isCustomizePanelOpen) setCustomizePanelOpen(false); else if (isServersPanelOpen) setServersPanelOpen(false); else if (isSessionsPanelOpen) setSessionsPanelOpen(false); else if (isMemoryPanelOpen) setMemoryPanelOpen(false); else if (isServerRegistryOpen) setServerRegistryOpen(false); else if (isExportOpen) setExportOpen(false); else if (showShortcuts) setShowShortcuts(false); else if (isDeleteDialogOpen) setDeleteDialogOpen(false); else if (errorMessage) setErrorMessage(null); else if (processing) cancel(currentSessionId || undefined); }, [ isCustomizePanelOpen, isServersPanelOpen, isSessionsPanelOpen, isMemoryPanelOpen, isServerRegistryOpen, isExportOpen, showShortcuts, isDeleteDialogOpen, errorMessage, processing, cancel, currentSessionId, ] ); return (
{/* Left Sidebar - Chat History (Desktop only - inline) */} {/* Always visible: collapsed (thin bar) or expanded (full panel) */}
setSessionsPanelOpen(false)} onExpand={() => setSessionsPanelOpen(true)} currentSessionId={currentSessionId} onSessionChange={handleSessionChange} returnToWelcome={handleReturnToWelcome} variant="inline" onSearchOpen={() => setSearchOpen(true)} onNewChat={handleReturnToWelcome} onSettingsOpen={() => setSettingsOpen(true)} onPlaygroundOpen={() => window.open('/playground', '_blank')} onThemeToggle={() => toggleTheme(theme === 'light')} theme={theme} />
{/* Chat History Panel - Mobile/Narrow (overlay) */}
setSessionsPanelOpen(false)} currentSessionId={currentSessionId} onSessionChange={handleSessionChange} returnToWelcome={handleReturnToWelcome} variant="overlay" onSearchOpen={() => setSearchOpen(true)} onNewChat={handleReturnToWelcome} onSettingsOpen={() => setSettingsOpen(true)} onPlaygroundOpen={() => window.open('/playground', '_blank')} onThemeToggle={() => toggleTheme(theme === 'light')} theme={theme} />
{/** Shared centered content width for welcome, messages, and composer */} {/** Keep this in sync to unify UI width like other chat apps */} {/** 720px base, expand to ~2xl on sm, ~3xl on lg */} {/* Unused var directive removed; keep code clean */} {(() => { /* no-op to allow inline constant-like usage below via variable */ return null; })()} {/* Clean Header */}
{/* Left Section */}
{/* Dexto Icon - Mobile only (desktop has collapsed sidebar) */}
Open Chat History (⌘H)
{/* Agent Selector */}
{/* Right Section - Desktop buttons (hide when session panel is open on smaller screens) */}
{/* Primary action group - Tools & Memories */}
{/* Tools */} Toggle tools panel (⌘J) {/* Memories */} Toggle memories panel (⌘M) {/* Customize Agent */} Customize Agent (⌘E)
{/* Always visible items */} setServerRegistryOpen(true)}> Connect MCPs setExportOpen(true)}> Export Config setShowShortcuts(true)}> Shortcuts {/* Session Management Actions - Only show when there's an active session */} {currentSessionId && !isWelcomeState && ( <> setDeleteDialogOpen(true)} className="text-destructive focus:text-destructive" > Delete Conversation )}
{/* Right Section - Narrow screens (hamburger menu) - also show on md when session panel open */}
{/* All action buttons for narrow screens */} { setCustomizePanelOpen(!isCustomizePanelOpen); setMobileMenuOpen(false); }} > Customize Agent { handleOpenServersPanel(); setMobileMenuOpen(false); }} > Tools { setMemoryPanelOpen(!isMemoryPanelOpen); setMobileMenuOpen(false); }} > Memories { toggleTheme(theme === 'light'); setMobileMenuOpen(false); }} > 🌙 Toggle Theme { setSettingsOpen(true); setMobileMenuOpen(false); }} > Settings {/* Always visible items */} { setServerRegistryOpen(true); setMobileMenuOpen(false); }} > Connect MCPs { window.open('/playground', '_blank'); setMobileMenuOpen(false); }} > MCP Playground { setExportOpen(true); setMobileMenuOpen(false); }} > Export Config { setShowShortcuts(true); setMobileMenuOpen(false); }} > Shortcuts {/* Session Management Actions - Only show when there's an active session */} {currentSessionId && !isWelcomeState && ( <> { setDeleteDialogOpen(true); setMobileMenuOpen(false); }} className="text-destructive focus:text-destructive" > Delete Conversation )}
{/* Main Content Area */}
{/* Toasts */} {successMessage && (
{successMessage}
)} {/* Error Message */} {errorMessage && (
{errorMessage}
)} {/* Chat Content */}
{isWelcomeState ? ( /* Modern Welcome Screen with Central Search */
{/* Greeting/Header Section - Narrowest */}

{greeting || 'Welcome to Dexto'}

Your AI assistant with powerful tools. Ask anything or connect new capabilities.

{/* Quick Actions Grid - Medium width */}
{dynamicQuickActions.map((action, index) => { const button = ( ); if (action.tooltip) { return ( {button} {action.tooltip} ); } return button; })}
{/* Central Input Bar - Narrowest, most focused */}
{/* Quick Tips */}

💡 Try ⌘K {' '} for new chat, ⌘J {' '} for tools, ⌘L {' '} for playground, ⌘⌫ {' '} to delete session, ⌘/ {' '} for shortcuts

) : ( /* Messages Area */
{/* Ensure the input dock sits at the very bottom even if content is short */}
{/* Sticky input dock inside scroll viewport */}
{showScrollHint && (
)}
{/* Scroll to bottom button */} {/* Scroll hint now rendered inside sticky dock */}
)}
{/* Narrow screens: overlay panel */}
setServersPanelOpen(false)} onOpenConnectModal={() => setModalOpen(true)} onOpenConnectWithPrefill={(opts) => { setConnectPrefill(opts); setModalOpen(true); }} onServerConnected={(name) => { setServersRefreshTrigger((prev) => prev + 1); setSuccessMessage(`Added ${name}`); setTimeout(() => setSuccessMessage(null), 4000); }} variant="overlay" refreshTrigger={serversRefreshTrigger} />
{/* Customize Panel - Overlay Animation */} setCustomizePanelOpen(false)} variant="overlay" /> {/* Connect Server Modal */} { setModalOpen(false); setIsRegistryBusy(false); setConnectPrefill(null); }} onServerConnected={async () => { if (connectPrefill?.registryEntryId) { try { await serverRegistry.setInstalled( connectPrefill.registryEntryId, true ); } catch (e) { console.warn('Failed to mark registry entry installed:', e); } } setServersRefreshTrigger((prev) => prev + 1); const name = connectPrefill?.name || 'Server'; setSuccessMessage(`Added ${name}`); setTimeout(() => setSuccessMessage(null), 4000); connectPrefill?.onCloseRegistryModal?.(); setIsRegistryBusy(false); setConnectPrefill(null); }} initialName={connectPrefill?.name} initialConfig={connectPrefill?.config} lockName={connectPrefill?.lockName} /> {/* Server Registry Modal */} setServerRegistryOpen(false)} onInstallServer={handleInstallServer} onOpenConnectModal={() => setModalOpen(true)} refreshTrigger={serversRefreshTrigger} disableClose={isRegistryBusy} /> {/* Export Configuration Modal */} Export Configuration Download your tool configuration for Claude Desktop or other MCP clients {currentSessionId && ( Including session-specific settings for:{' '} {currentSessionId} )}
setExportName(e.target.value)} placeholder="dexto-config" className="font-mono" />
{exportError && ( Export Error {exportError} )} {exportContent && (