OwnGPT.v2 / client /src /AppShell.jsx
parthib07's picture
Upload 199 files
212c959 verified
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 (
<div
className={clsx(
'relative flex h-screen overflow-hidden bg-background text-foreground transition-[padding] duration-300',
sidebarOpen ? 'lg:pl-[288px]' : 'lg:pl-0',
)}
>
<div className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<div className="app-background absolute inset-0" />
</div>
<Sidebar
open={sidebarOpen}
sessions={sessions}
loading={sessionsQuery.isLoading && !sessions.length}
currentSessionId={currentSessionId}
searchValue={sessionSearch}
onSearchChange={setSessionSearch}
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSessionRequest}
onNewSession={handleNewChat}
onClose={() => setSidebarOpen(false)}
stats={stats}
insights={insightsQuery.data}
/>
<div className="flex min-w-0 flex-1 flex-col">
<AppHeader
title={appTitle}
theme={theme}
themePreference={themePreference}
currentSessionId={currentSessionId}
canRenameSession={Boolean(user && !sharedConversation)}
hasMessages={Boolean(messages.length)}
onToggleSidebar={toggleSidebar}
onThemePreferenceChange={setThemePreference}
onNewChat={handleNewChat}
onRenameSession={() =>
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 ? (
<div className="border-b border-border bg-background px-4 py-3 sm:px-6">
<div className="mx-auto flex max-w-3xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Link2 className="h-4 w-4 text-muted-foreground" />
Shared conversation
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" onClick={handleNewChat}>
<Copy className="h-4 w-4" />
New chat
</Button>
<Button
variant="ghost"
onClick={() => {
clearShareHash()
clearSharedConversation()
}}
>
<X className="h-4 w-4" />
Dismiss
</Button>
</div>
</div>
</div>
) : null}
<div className="flex min-h-0 flex-1">
<div className="flex min-w-0 flex-1 flex-col">
<ChatArea
messages={messages}
loading={isLoadingHistory}
user={user}
onCopyMessage={handleCopyMessage}
onRegenerate={regenerateFromAssistant}
onEdit={(message) => openModal('editMessage', message)}
onSpeak={toggleSpeech}
onPreviewFile={previewFile}
speakingMessageId={speakingMessageId}
/>
<Composer
text={composerText}
onTextChange={setComposerText}
attachments={attachments}
onRemoveAttachment={removeAttachment}
onUpload={uploadFiles}
onSend={handleSubmitMessage}
onAskAboutFile={askAboutFile}
onPreviewFile={previewFile}
mode={mode}
onModeChange={setMode}
provider={activeProviderLabel}
model={model}
onOpenModels={() => openModal('model')}
disabled={!isAuthenticated}
streaming={isStreaming}
onRequireAuth={() => openAuth('login')}
/>
</div>
<AnimatePresence initial={false}>
<WorkspacePanel
sidePanel={sidePanel}
selectedFile={selectedFile}
stats={stats}
insights={insightsQuery.data}
isAuthenticated={isAuthenticated}
runtimeStatus={runtimeStatus}
activeProvider={activeProviderLabel}
activeModel={model}
onClose={handleClosePanel}
onPreviewAnalytics={() => setSidePanel('analytics')}
onAskAboutFile={askAboutFile}
/>
</AnimatePresence>
</div>
</div>
<AuthDialog
open={isAuthDialogOpen}
mode={authMode || 'login'}
onModeChange={openAuth}
onClose={closeAuth}
onLogin={login}
onRegister={register}
onGoogleLogin={loginWithGoogle}
pending={pendingAuth}
/>
<FeedbackDialog
open={activeModal === 'feedback'}
onOpenChange={(next) => {
if (!next) closeModal()
}}
onSubmit={submitFeedback}
pending={feedbackPending}
/>
<ModelDialog
open={activeModal === 'model'}
onOpenChange={(next) => {
if (!next) closeModal()
}}
registry={registry}
currentProvider={provider}
currentModel={model}
onSelect={setProviderModel}
/>
<ConfirmDialog
open={activeModal === 'deleteSession'}
onOpenChange={(next) => {
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
/>
<Modal
open={activeModal === 'renameSession'}
onOpenChange={(next) => {
if (!next) closeModal()
}}
size="sm"
title="Rename conversation"
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={closeModal}>
Cancel
</Button>
<Button variant="primary" onClick={handleRenameSubmit} disabled={!renameDraft.trim()}>
Save title
</Button>
</div>
}
>
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">Conversation title</span>
<div className="field rounded-lg px-4 py-3">
<input
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
placeholder="Product launch planning"
className="w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
</label>
</Modal>
<Modal
open={activeModal === 'editMessage'}
onOpenChange={(next) => {
if (!next) closeModal()
}}
size="md"
title="Edit user prompt"
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={closeModal}>
Cancel
</Button>
<Button variant="primary" onClick={handleEditSubmit} disabled={!editDraft.trim()}>
<Sparkles className="h-4 w-4" />
Run new branch
</Button>
</div>
}
>
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">Prompt</span>
<div className="field rounded-lg px-4 py-3">
<textarea
value={editDraft}
onChange={(event) => setEditDraft(event.target.value)}
className="min-h-[220px] w-full bg-transparent text-sm leading-7 text-foreground outline-none placeholder:text-muted-foreground"
placeholder="Refine the original message and branch from it..."
/>
</div>
</label>
</Modal>
<Modal
open={activeModal === 'installHelp'}
onOpenChange={(next) => {
if (!next) closeModal()
}}
size="sm"
title="Install OwnGPT"
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={closeModal}>
Close
</Button>
<Button variant="primary" onClick={closeModal}>
<Download className="h-4 w-4" />
I understand
</Button>
</div>
}
>
<div className="space-y-4">
<div className="surface-soft p-4">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Recommended steps
</p>
<div className="mt-3 space-y-3 text-sm leading-6 text-foreground">
{getInstallSteps(modalPayload?.platform || platform, modalPayload?.instructions || manualInstructions).map((step, index) => (
<div key={step} className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-lg bg-accent/10 text-xs font-semibold text-accent">
{index + 1}
</span>
<span>{step}</span>
</div>
))}
</div>
</div>
<div className="rounded-lg border border-border bg-background px-4 py-4 text-sm leading-6 text-muted-foreground">
{modalPayload?.instructions || manualInstructions}
</div>
</div>
</Modal>
<Toaster />
</div>
)
}