<template>
<div class="settings-view">
<!-- Literally the chat header: same global .chat-header bar -->
<div class="chat-header">
<GnIconButton
icon="ph ph-sidebar"
label="Menu"
class="btn-sidebar-toggle"
@click="emit('toggle-sidebar')"
/>
<div class="chat-header-info">
<span class="chat-header-title">Settings</span>
</div>
</div>
<GnTabs ref="tabsRoot" v-model="activeTab" :items="tabs" class="settings-tabs" ariaLabel="Settings sections">
<template #app>
<AppPanel v-if="activeTab === 'app'" />
</template>
<template #account>
<ApiKeysPanel v-if="activeTab === 'account'" />
</template>
<template #notifications>
<NotificationSettingsPanel v-if="activeTab === 'notifications'" />
</template>
<template #synapse>
<SynapseReactionsPanel v-if="activeTab === 'synapse'" />
<SynapseTargetsPanel v-if="activeTab === 'synapse'" />
</template>
<template #mcp>
<McpKeysPanel v-if="activeTab === 'mcp'" />
<!-- Same tab, admins only: the reload rewrites the toolset of the
whole server, not just this user's MCP keys. -->
<AdminToolsPanel v-if="activeTab === 'mcp' && authStore.isAdmin" />
</template>
</GnTabs>
</div>
</template>
<script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { GnIconButton, GnTabs } from 'gnexus-ui-kit/vue'
import { useAuthStore } from '@/stores/auth.js'
import { isSettingsHash, settingsHash, settingsTabFromHash } from '@/utils/hash.js'
import ApiKeysPanel from './ApiKeysPanel.vue'
import NotificationSettingsPanel from './NotificationSettingsPanel.vue'
import SynapseReactionsPanel from './SynapseReactionsPanel.vue'
import SynapseTargetsPanel from './SynapseTargetsPanel.vue'
import McpKeysPanel from './McpKeysPanel.vue'
import AdminToolsPanel from './AdminToolsPanel.vue'
import AppPanel from './AppPanel.vue'
const emit = defineEmits(['toggle-sidebar'])
const authStore = useAuthStore()
const tabs = [
{ id: 'app', label: 'App', icon: 'ph ph-device-mobile' },
{ id: 'account', label: 'Account', icon: 'ph ph-user-circle' },
{ id: 'notifications', label: 'Notifications', icon: 'ph ph-bell' },
{ id: 'synapse', label: 'Synapse', icon: 'ph ph-network' },
{ id: 'mcp', label: 'MCP', icon: 'ph ph-plugs-connected' },
]
const DEFAULT_TAB = tabs[0].id
const tabIds = new Set(tabs.map(tab => tab.id))
// `#settings/<tab>` opens that section, plain `#settings` (the sidebar button)
// and an unknown id both land on the first tab.
function tabFromHash(hash) {
const id = settingsTabFromHash(hash)
return id && tabIds.has(id) ? id : DEFAULT_TAB
}
const activeTab = ref(tabFromHash(location.hash))
// On narrow screens the kit tab strip scrolls horizontally, but nothing
// reveals the active button after a switch — pull it into view.
const tabsRoot = ref(null)
watch(activeTab, (id) => {
// replaceState, not location.hash: a tab is not somewhere you navigated to,
// so Back leaves Settings instead of walking back through the tabs. The
// address stays linkable all the same.
history.replaceState(history.state, '', settingsHash(id))
nextTick(() => {
const list = tabsRoot.value?.$el
const active = list?.querySelector?.('[role="tab"][aria-selected="true"]')
active?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
})
})
// The hash can also change from outside — the sidebar's Settings button writes
// plain `#settings`, which should return the reader to the first tab.
function handleHashChange() {
if (!isSettingsHash(location.hash)) return
activeTab.value = tabFromHash(location.hash)
}
onMounted(() => {
window.addEventListener('hashchange', handleHashChange)
// Canonicalise: an unknown id resolved to the first tab above, and the
// address bar should name the section actually on screen. replaceState fires
// no hashchange, so this cannot loop.
if (settingsTabFromHash(location.hash) !== activeTab.value) {
history.replaceState(history.state, '', settingsHash(activeTab.value))
}
})
onUnmounted(() => {
window.removeEventListener('hashchange', handleHashChange)
})
</script>
<style scoped lang="scss">
@use 'kit-deps' as *;
$bp-mobile: 767px;
// Matches .chat-area: fills .app-main, no scroll of its own — the single
// scroll region lives inside the active tab panel below.
.settings-view {
flex: 1 1 0;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
// Tab strip is fixed; the scroll region is the active panel below.
// Kit's .tabs is a wrapping row flex — straighten it into a column so the
// panels can claim the leftover height. nowrap + stretch: without them the
// column wrap pushes the panels block (and with it the tab strip, via the
// kit's centering) off-screen to the right on narrow phones.
.settings-tabs {
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
width: 100%;
max-width: 1200px;
flex: 1 1 0;
min-height: 0;
padding: $space-sm $space-md 0;
margin-bottom: 0;
:deep(.tabs-list) {
width: 100%;
max-width: none;
}
// The single scroll region: full-bleed, scrollbar against the app-main edge
:deep(.tabs-panels) {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: $space-md $space-md $space-xl;
@media (max-width: $bp-mobile) {
padding: $space-sm $space-sm $space-lg;
}
}
}
@media (max-width: $bp-mobile) {
.settings-tabs {
padding-left: $space-sm;
padding-right: $space-sm;
}
}
</style>