<template>
<GnToastProvider>
<!-- A shared chat is a whole page of its own: no sidebar, no artifacts, no
login gate, no websocket. It must stay usable with no account at all,
so it branches before anything that talks to the authenticated app. -->
<div v-if="shareToken" class="app-shell">
<SharedChatView :key="shareToken" :token="shareToken" />
</div>
<div v-else class="app-shell">
<!-- Mobile sidebar backdrop -->
<div
v-if="sidebarOpen"
class="sidebar-backdrop"
@click="closeSidebar"
/>
<Transition name="backdrop-fade">
<div
v-if="artifactsOpen"
class="artifacts-backdrop"
@click="closeArtifacts"
/>
</Transition>
<!-- Sidebar (desktop: always visible; mobile: drawer) -->
<AppSidebar
:mobile-open="sidebarOpen"
:class="{ 'is-hidden-by-artifacts': artifactsOpen }"
@close="closeSidebar"
/>
<!-- Global confirm dialog -->
<ConfirmDialog />
<!-- Global image lightbox -->
<ContentLightbox />
<!-- Selection reply toolbar -->
<SelectionToolbar />
<!-- Offline indicator: browser lost the network (shell still opens from SW cache) -->
<OfflineBanner />
<!-- Main content -->
<div class="app-main">
<!-- Push permission: offered once per browser session, and only to a
signed-in user. A full-width bar, so it lives in this column rather
than beside it (see .app-shell — that is a row). -->
<NotificationPromptBanner
:open="promptVisible && route !== 'settings'"
:busy="promptBusy"
:title="promptTitle"
:text="promptText"
:accept-text="promptAcceptText"
:dismiss-text="promptDismissText"
@accept="acceptPushPrompt"
@dismiss="declinePushPrompt"
/>
<Transition name="fade" mode="out-in">
<WelcomeScreen
v-if="showWelcome"
key="welcome"
@toggle-sidebar="toggleSidebar"
/>
<SettingsView
v-else-if="route === 'settings'"
key="settings"
@toggle-sidebar="toggleSidebar"
/>
<ChatArea
v-else
key="chat"
@toggle-sidebar="toggleSidebar"
@toggle-artifacts="toggleArtifacts"
/>
</Transition>
</div>
<ArtifactsPanel
:open="artifactsOpen"
@close="closeArtifacts"
/>
<!-- Login overlay: blocks the UI when auth is required but user is not logged in -->
<LoginScreen
v-if="authStore.authConfigured && !authStore.isAuthenticated && !authStore.loading"
/>
</div>
</GnToastProvider>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useSessionsStore } from '@/stores/sessions'
import { useProfilesStore } from '@/stores/profiles'
import { useChatStore } from '@/stores/chat'
import { useAuthStore } from '@/stores/auth'
import { useNotificationPrompt } from '@/composables/useNotificationPrompt.js'
import { isSettingsHash, shareTokenFromHash } from '@/utils/hash.js'
import { setLocale, storedLocale, t } from '@/i18n/index.js'
import AppSidebar from '@/components/sidebar/AppSidebar.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
import ContentLightbox from '@/components/ui/ContentLightbox.vue'
import SelectionToolbar from '@/components/ui/SelectionToolbar.vue'
import ArtifactsPanel from '@/components/artifacts/ArtifactsPanel.vue'
import LoginScreen from '@/components/ui/LoginScreen.vue'
import NotificationPromptBanner from '@/components/ui/NotificationPromptBanner.vue'
import OfflineBanner from '@/components/ui/OfflineBanner.vue'
import WelcomeScreen from '@/components/ui/WelcomeScreen.vue'
import ChatArea from '@/components/chat/ChatArea.vue'
import SettingsView from '@/components/settings/SettingsView.vue'
import SharedChatView from '@/views/SharedChatView.vue'
const sessionsStore = useSessionsStore()
const profilesStore = useProfilesStore()
const chatStore = useChatStore()
const authStore = useAuthStore()
// The offer to turn push notifications on: shown once per browser session,
// from here rather than from the settings screen, whose switch is the only
// way back after an explicit block.
const {
visible: promptVisible,
busy: promptBusy,
title: promptTitle,
text: promptText,
acceptText: promptAcceptText,
dismissText: promptDismissText,
maybeShow: maybeShowPushPrompt,
accept: acceptPushPrompt,
decline: declinePushPrompt,
} = useNotificationPrompt()
const sidebarOpen = ref(false)
const artifactsOpen = ref(false)
// `#share=<token>` is a public read-only page: it needs no account, so it
// branches around everything below the shell (login gate, websocket, stores).
const shareToken = ref(shareTokenFromHash(location.hash))
function getRouteFromHash() {
const hash = location.hash
if (shareTokenFromHash(hash)) return 'share'
if (isSettingsHash(hash)) return 'settings'
return 'chat'
}
const route = ref(getRouteFromHash())
const showWelcome = computed(() => route.value === 'chat' && !chatStore.currentId && !chatStore.loading)
const documentTitle = computed(() => {
// The shared view names its own document once the chat loads.
if (route.value === 'share') return null
if (route.value === 'settings') return t('app.titleSettings')
if (!chatStore.currentId) return 'Navi'
const id = chatStore.currentId
// nameById persists beyond the loaded list page (the list holds only ~30)
return (
sessionsStore.nameById[id] ||
sessionsStore.sessions.find(s => s.session_id === id)?.name ||
id.slice(0, 8)
)
})
watch(documentTitle, (title) => {
if (title) document.title = title
}, { immediate: true })
function closeSidebar() {
sidebarOpen.value = false
}
function closeArtifacts() {
artifactsOpen.value = false
}
function toggleSidebar() {
const next = !sidebarOpen.value
if (next) artifactsOpen.value = false
sidebarOpen.value = next
}
function toggleArtifacts() {
const next = !artifactsOpen.value
if (next) sidebarOpen.value = false
artifactsOpen.value = next
}
let bootstrapped = false
// Everything the authenticated app needs before it can render a chat. The
// shared page skips it entirely; a hash that leaves the shared page runs it
// late, so no tab can land in the shell with no profiles or session list.
async function bootstrapApp({ loadHash = true } = {}) {
if (bootstrapped) return
bootstrapped = true
// Check auth configuration first, before any protected API calls
await authStore.fetchStatus()
// Resolve auth state only when auth is configured on the backend.
// In disabled-auth mode we skip /auth/me to avoid an unnecessary 401.
if (authStore.authConfigured) {
try {
await authStore.fetchMe()
} catch {
// unauthenticated — LoginScreen will show if auth is configured
}
}
// Interface language: the account's own, with whatever was chosen in
// Settings, as soon as there is an account — this browser's choice otherwise.
setLocale(authStore.user ? authStore.user.locale_effective : storedLocale())
// Load app data only if authenticated or auth is not configured
if (!authStore.authConfigured || authStore.isAuthenticated) {
try {
await profilesStore.fetchProfiles()
await sessionsStore.fetchSessions(profilesStore.selectedProfileId)
} catch {
// ignore
}
}
// Load session from initial hash after auth is resolved
if (loadHash && location.hash && !isSettingsHash(location.hash) && !shareTokenFromHash(location.hash)) {
const id = location.hash.slice(1)
if (id) chatStore.loadSession(id)
}
// Offer push notifications once the app is actually usable. The SSO return
// is a full reload, so this is the whole "after login" hook — no watcher.
if (!authStore.authConfigured || authStore.isAuthenticated) {
await maybeShowPushPrompt()
}
}
function handleHashChange() {
const wasShared = route.value === 'share'
shareToken.value = shareTokenFromHash(location.hash)
route.value = getRouteFromHash()
if (shareToken.value) {
// Leaving the authenticated app behind: the drawer must not linger over
// the shared page, and no session may be loaded from the token.
sidebarOpen.value = false
artifactsOpen.value = false
return
}
// The handler below already opens the session this hash names.
if (wasShared) bootstrapApp({ loadHash: false })
const hash = location.hash
if (isSettingsHash(hash)) return
const id = hash.slice(1)
if (id && id !== chatStore.currentId) {
chatStore.loadSession(id)
}
}
onMounted(async () => {
// Hash-based routing
window.addEventListener('hashchange', handleHashChange)
if (shareToken.value) {
// Public page: no auth, no profiles, no session list, no push prompt.
// Without an account the only language left is this browser's own choice.
setLocale(storedLocale())
return
}
await bootstrapApp()
})
onUnmounted(() => {
window.removeEventListener('hashchange', handleHashChange)
})
</script>
<style scoped>
.app-shell {
display: flex;
flex: 1 1 0;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.sidebar-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 99;
@media (max-width: 1279.98px) {
display: block;
}
}
.artifacts-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 119;
@media (max-width: 980px) {
display: block;
}
}
.backdrop-fade-enter-active,
.backdrop-fade-leave-active {
transition: opacity 0.18s ease;
}
.backdrop-fade-enter-from,
.backdrop-fade-leave-to {
opacity: 0;
}
</style>