import { useDeferredValue, useEffect, useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' import clsx from 'clsx' import { AnimatePresence } from 'framer-motion' import { Copy, Download, Link2, Sparkles, X } from 'lucide-react' import ChatArea from './components/Chat/ChatArea' import Composer from './components/Composer/Composer' import Sidebar from './components/Sidebar/Sidebar' import AuthDialog from './components/UI/AuthDialogPanel' import AppHeader from './components/UI/AppHeader' import Button from './components/UI/Button' import ConfirmDialog from './components/UI/ConfirmDialog' import FeedbackDialog from './components/UI/FeedbackDialog' import Modal from './components/UI/Modal' import ModelDialog from './components/UI/ModelDialog' import Toaster from './components/UI/Toaster' import WorkspacePanel from './components/UI/WorkspacePanel' import { useAuth } from './hooks/useAuth' import { useChat } from './hooks/useChat' import { useInstallPrompt } from './hooks/useInstallPrompt' import { decodeSharePayload, useAppStore } from './store/useAppStore' import { api } from './utils/api' function isSmallViewport() { return typeof window !== 'undefined' && window.innerWidth < 1024 } function clearShareHash() { if (typeof window === 'undefined' || !window.location.hash.startsWith('#share=')) return window.history.replaceState({}, '', window.location.pathname + window.location.search) } function buildRenamePayload(sessionId, title) { return sessionId ? { session_id: sessionId, title } : { title, isDraft: true } } function getEnabledProviderIds(registry = {}) { return Object.entries(registry) .filter(([, providerEntry]) => providerEntry?.enabled !== false) .map(([providerId]) => providerId) } function getPreferredModelId(providerId, providerEntry, defaultProvider, defaultModel) { if (!providerEntry?.models?.length) { return defaultModel } if (providerId === defaultProvider && providerEntry.models.some((entry) => entry.id === defaultModel)) { return defaultModel } return providerEntry.models[0]?.id || defaultModel } function getInstallSteps(platform, instructions) { if (platform === 'ios') { return [ 'Open OwnGPT in Safari on your iPhone or iPad.', 'Tap the Share button in the browser toolbar.', 'Choose Add to Home Screen, then confirm Install.', ] } if (platform === 'android') { return [ 'Open the browser menu in Chrome or Edge.', 'Choose Install app or Add to Home screen.', 'Confirm the install prompt to keep OwnGPT on your device.', ] } return [ 'Open the browser menu near the address bar.', 'Choose Install OwnGPT, Install app, or Create shortcut.', 'Launch the installed app for a cleaner desktop workspace.', ] } export default function AppShell() { const { token, user, isAuthenticated, authMode, isAuthDialogOpen, openAuth, closeAuth, login, register, loginPending, registerPending, logout, loginWithGoogle, requireAuth, } = useAuth() const theme = useAppStore((state) => state.theme) const themePreference = useAppStore((state) => state.themePreference) const setThemePreference = useAppStore((state) => state.setThemePreference) const syncSystemTheme = useAppStore((state) => state.syncSystemTheme) const sidebarOpen = useAppStore((state) => state.sidebarOpen) const setSidebarOpen = useAppStore((state) => state.setSidebarOpen) const toggleSidebar = useAppStore((state) => state.toggleSidebar) const currentSessionId = useAppStore((state) => state.currentSessionId) const currentSessionTitle = useAppStore((state) => state.currentSessionTitle) const composerText = useAppStore((state) => state.composerText) const setComposerText = useAppStore((state) => state.setComposerText) const attachments = useAppStore((state) => state.attachments) const removeAttachment = useAppStore((state) => state.removeAttachment) const mode = useAppStore((state) => state.mode) const setMode = useAppStore((state) => state.setMode) const provider = useAppStore((state) => state.provider) const model = useAppStore((state) => state.model) const setProviderModel = useAppStore((state) => state.setProviderModel) const sessionSearch = useAppStore((state) => state.sessionSearch) const setSessionSearch = useAppStore((state) => state.setSessionSearch) const sidePanel = useAppStore((state) => state.sidePanel) const setSidePanel = useAppStore((state) => state.setSidePanel) const selectedFile = useAppStore((state) => state.selectedFile) const clearSelectedFile = useAppStore((state) => state.clearSelectedFile) const sharedConversation = useAppStore((state) => state.sharedConversation) const setSharedConversation = useAppStore((state) => state.setSharedConversation) const clearSharedConversation = useAppStore((state) => state.clearSharedConversation) const startNewChat = useAppStore((state) => state.startNewChat) const renameCurrentSession = useAppStore((state) => state.renameCurrentSession) const activeModal = useAppStore((state) => state.activeModal) const modalPayload = useAppStore((state) => state.modalPayload) const openModal = useAppStore((state) => state.openModal) const closeModal = useAppStore((state) => state.closeModal) const addToast = useAppStore((state) => state.addToast) const deferredSearch = useDeferredValue(sessionSearch) const [renameDraft, setRenameDraft] = useState('') const [editDraft, setEditDraft] = useState('') const { messages, isLoadingHistory, isStreaming, currentMode, uploadFiles, sendMessage, selectSession, renameSession, deleteSession, submitFeedback, feedbackPending, askAboutFile, branchFromUserMessage, regenerateFromAssistant, exportChatAsMarkdown, exportChatAsPdf, shareConversation, toggleSpeech, speakingMessageId, } = useChat({ token, user }) const { install, installState, platform, manualInstructions } = useInstallPrompt() const previewFile = useAppStore.getState().previewFile const modelsQuery = useQuery({ queryKey: ['models'], queryFn: () => api.get('/models'), staleTime: 300_000, }) const healthQuery = useQuery({ queryKey: ['healthz-ready'], queryFn: () => api.get('/healthz/ready'), staleTime: 15_000, retry: 0, refetchInterval: 30_000, }) const sessionsQuery = useQuery({ queryKey: ['sessions', token || 'guest', deferredSearch], queryFn: () => api.get( deferredSearch?.trim() ? `/search?query=${encodeURIComponent(deferredSearch.trim())}` : '/search', { token }, ), enabled: isAuthenticated, placeholderData: (previous) => previous, staleTime: 15_000, }) const insightsQuery = useQuery({ queryKey: ['workspace-insights', token || 'guest'], queryFn: () => api.get('/insights/workspace', { token }), enabled: isAuthenticated, staleTime: 30_000, refetchInterval: 60_000, }) const registry = modelsQuery.data?.providers || {} const sessions = sessionsQuery.data?.sessions || [] useEffect(() => { const nextRegistry = modelsQuery.data?.providers if (!nextRegistry) return const defaultProvider = modelsQuery.data.default_provider const defaultModel = modelsQuery.data.default_model const enabledProviderIds = getEnabledProviderIds(nextRegistry) const resolvedProvider = nextRegistry[provider] && nextRegistry[provider].enabled !== false ? provider : enabledProviderIds[0] || defaultProvider const providerEntry = nextRegistry[resolvedProvider] const preferredModel = getPreferredModelId( resolvedProvider, providerEntry, defaultProvider, defaultModel, ) if (!providerEntry) { setProviderModel(defaultProvider, defaultModel) return } const modelExists = providerEntry.models.some((entry) => entry.id === model) if (resolvedProvider !== provider || !modelExists) { setProviderModel(resolvedProvider, modelExists ? model : preferredModel) } }, [model, modelsQuery.data, provider, setProviderModel]) useEffect(() => { if (activeModal === 'renameSession') { setRenameDraft(modalPayload?.title || '') } if (activeModal === 'editMessage') { setEditDraft(modalPayload?.content || '') } }, [activeModal, modalPayload]) useEffect(() => { if (typeof window === 'undefined') return undefined const syncSharedConversation = () => { const payload = decodeSharePayload(window.location.hash) if (payload?.messages?.length) { setSharedConversation(payload) setSidePanel('analytics') } else if (window.location.hash.startsWith('#share=')) { clearSharedConversation() } } syncSharedConversation() window.addEventListener('hashchange', syncSharedConversation) return () => window.removeEventListener('hashchange', syncSharedConversation) }, [clearSharedConversation, setSharedConversation, setSidePanel]) useEffect(() => { if (typeof window === 'undefined') return undefined const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') const handleThemeChange = () => syncSystemTheme() mediaQuery.addEventListener?.('change', handleThemeChange) return () => mediaQuery.removeEventListener?.('change', handleThemeChange) }, [syncSystemTheme]) const stats = useMemo( () => ({ sessionCount: sessions.length, messageCount: messages.length, attachmentCount: attachments.length, modeLabel: currentMode.label, modeDescription: currentMode.description, totalSessions: insightsQuery.data?.total_sessions || 0, totalMessages: insightsQuery.data?.total_messages || 0, totalAttachments: insightsQuery.data?.total_attachments || 0, activeDays: insightsQuery.data?.active_days || 0, averageMessagesPerSession: insightsQuery.data?.average_messages_per_session || 0, }), [ attachments.length, currentMode.description, currentMode.label, insightsQuery.data?.active_days, insightsQuery.data?.average_messages_per_session, insightsQuery.data?.total_attachments, insightsQuery.data?.total_messages, insightsQuery.data?.total_sessions, messages.length, sessions.length, ], ) const pendingAuth = loginPending || registerPending const handleNewChat = () => { clearShareHash() clearSharedConversation() startNewChat() if (isSmallViewport()) { setSidebarOpen(false) } } const handleSelectSession = (session) => { clearShareHash() clearSharedConversation() selectSession(session) if (isSmallViewport()) { setSidebarOpen(false) } } const handleDeleteSessionRequest = (session) => { if (!session?.session_id) return openModal('deleteSession', { session_id: session.session_id, title: session.title || 'this chat', }) } const handleSubmitMessage = async (text) => { await sendMessage({ text }) } const handleCopyMessage = async (message) => { try { await navigator.clipboard.writeText(message.content || '') addToast({ title: 'Message copied', description: 'The response is now in your clipboard.', variant: 'success', }) } catch (error) { addToast({ title: 'Copy failed', description: error.message || 'Clipboard access is unavailable.', variant: 'danger', }) } } const handleOpenFeedback = () => { requireAuth(() => openModal('feedback')) } const handleShareConversation = async () => { if (!messages.length) { addToast({ title: 'Nothing to share yet', description: 'Start a conversation first, then generate a shareable link.', variant: 'info', }) return } await shareConversation() } const handleRenameSubmit = async () => { const title = renameDraft.trim() if (!title) return if (modalPayload?.session_id) { await renameSession({ sessionId: modalPayload.session_id, title }) } else { renameCurrentSession(title) } closeModal() } const handleDeleteSubmit = async () => { if (!modalPayload?.session_id) return await deleteSession(modalPayload.session_id) closeModal() } const handleEditSubmit = async () => { const text = editDraft.trim() if (!text || !modalPayload) return await branchFromUserMessage(modalPayload, text) closeModal() } const handleClosePanel = () => { if (sidePanel === 'file') { clearSelectedFile() return } setSidePanel(null) } const handleInstallApp = async () => { const result = await install() if (result.status === 'accepted') { addToast({ title: 'Install started', description: 'Confirm the browser prompt to finish installing OwnGPT.', variant: 'success', }) return } if (result.status === 'dismissed') { addToast({ title: 'Install dismissed', description: 'You can install OwnGPT anytime from the header button.', variant: 'info', }) return } if (result.status === 'installed') { addToast({ title: 'Already installed', description: 'OwnGPT is already available as an app on this device.', variant: 'info', }) return } openModal('installHelp', result) } const activeProviderLabel = registry[provider]?.label || provider const appTitle = sharedConversation ? sharedConversation.title || 'Shared Conversation' : currentSessionTitle const runtimeStatus = healthQuery.data || { status: healthQuery.isError ? 'degraded' : 'checking', version: 'unknown', checks: { mongo: healthQuery.isError ? 'unreachable' : 'checking', vector_memory: 'unknown', }, enabled_providers: getEnabledProviderIds(registry), } return (
setSidebarOpen(false)} stats={stats} insights={insightsQuery.data} />
openModal('renameSession', buildRenamePayload(currentSessionId, currentSessionTitle)) } onDeleteSession={() => currentSessionId ? openModal('deleteSession', { session_id: currentSessionId, title: currentSessionTitle, }) : null } onShare={handleShareConversation} onExportMarkdown={exportChatAsMarkdown} onExportPdf={exportChatAsPdf} onToggleAnalytics={() => setSidePanel(sidePanel === 'analytics' ? null : 'analytics')} onFeedback={handleOpenFeedback} user={user} onLogin={() => openAuth('login')} onLogout={logout} /> {sharedConversation ? (
Shared conversation
) : null}
openModal('editMessage', message)} onSpeak={toggleSpeech} onPreviewFile={previewFile} speakingMessageId={speakingMessageId} /> openModal('model')} disabled={!isAuthenticated} streaming={isStreaming} onRequireAuth={() => openAuth('login')} />
setSidePanel('analytics')} onAskAboutFile={askAboutFile} />
{ if (!next) closeModal() }} onSubmit={submitFeedback} pending={feedbackPending} /> { if (!next) closeModal() }} registry={registry} currentProvider={provider} currentModel={model} onSelect={setProviderModel} /> { if (!next) closeModal() }} title="Delete conversation?" description={ modalPayload?.title ? `This will permanently remove "${modalPayload.title}" and its saved messages.` : 'This will permanently remove the selected conversation and its saved messages.' } onConfirm={handleDeleteSubmit} confirmLabel="Delete" destructive /> { if (!next) closeModal() }} size="sm" title="Rename conversation" footer={
} >
{ if (!next) closeModal() }} size="md" title="Edit user prompt" footer={
} >