feat(#209): rework sidebar nav (Home / Docs / Stores / Runs / Settings)

The sidebar was the project's actual nav (the design doc called it
'top nav' but the existing app is sidebar-driven). Five entries
reflecting the doc-centric IA replace the seven analysis-centric ones.

Sidebar
- shared/ui/AppSidebar.vue: data-driven over a NavItem[] table
  instead of seven hard-coded RouterLinks. Inline SVG icon
  components keep the visual weight close to the previous version
  with no new dependency.
- Five entries: Home / Docs (★ primary, bolder label) / Stores /
  Runs / Settings.
- Active state via matchesActive(path, prefixes): exact match for
  Home, prefix match (with segment / query boundary) for the others.
  Pure helper in shared/ui/navActive.ts, unit tested.

Removed from the sidebar (legacy pages still reachable by URL):
  Studio, Documents, Search, Reasoning, History.

i18n
- nav.docs / nav.stores / nav.runs added in fr + en, grouped under
  the 0.6.0 nav block.
- Legacy nav labels (nav.studio / nav.documents / nav.history /
  nav.reasoning / nav.search) kept — the legacy pages still render
  headings using them. Duplicate nav.search entries removed (the key
  now lives once per locale, in the nav block).

Tests
- shared/ui/navActive.test.ts (5 cases): exact-match for Home,
  prefix boundary semantics, multi-prefix matching, empty list edge.

Refs #209
This commit is contained in:
Pier-Jean Malandrino 2026-04-29 17:45:14 +02:00
parent ab5de2aac3
commit 058d718116
4 changed files with 194 additions and 118 deletions

View file

@ -6,15 +6,22 @@ type Messages = Record<Locale, MessageMap>
const messages: Messages = { const messages: Messages = {
fr: { fr: {
// Sidebar // Sidebar — 0.6.0 doc-centric nav (#209)
'nav.home': 'Accueil', 'nav.home': 'Accueil',
'nav.docs': 'Documents',
'nav.stores': 'Stores',
'nav.runs': 'Runs',
'nav.settings': 'Paramètres',
'nav.collapse': 'Réduire la barre latérale',
'nav.expand': 'Développer la barre latérale',
// Legacy nav labels — kept because the legacy pages (/studio, /documents,
// /history, /search, /reasoning) still render headings using them. They
// are no longer surfaced in the sidebar.
'nav.studio': 'Studio', 'nav.studio': 'Studio',
'nav.documents': 'Documents', 'nav.documents': 'Documents',
'nav.history': 'Historique', 'nav.history': 'Historique',
'nav.reasoning': 'Raisonnement', 'nav.reasoning': 'Raisonnement',
'nav.settings': 'Paramètres', 'nav.search': 'Recherche',
'nav.collapse': 'Réduire la barre latérale',
'nav.expand': 'Développer la barre latérale',
// Top bar // Top bar
'topbar.newAnalysis': 'Nouvelle analyse', 'topbar.newAnalysis': 'Nouvelle analyse',
@ -251,8 +258,7 @@ const messages: Messages = {
'chunking.batchNotice': 'chunking.batchNotice':
'Le chunking n\u2019est pas disponible pour cette analyse. Les documents volumineux trait\u00e9s par batch ne g\u00e9n\u00e8rent pas la structure interne n\u00e9cessaire au d\u00e9coupage. Coming soon !', 'Le chunking n\u2019est pas disponible pour cette analyse. Les documents volumineux trait\u00e9s par batch ne g\u00e9n\u00e8rent pas la structure interne n\u00e9cessaire au d\u00e9coupage. Coming soon !',
// Search // Search (legacy nav label moved to the top with the other legacy keys)
'nav.search': 'Recherche',
'search.hint': 'Saisissez un terme pour rechercher dans les chunks indexés.', 'search.hint': 'Saisissez un terme pour rechercher dans les chunks indexés.',
// Ingestion / My Documents // Ingestion / My Documents
@ -303,14 +309,20 @@ const messages: Messages = {
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max {n} Mo). Ne pas envoyer de fichiers confidentiels.', 'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max {n} Mo). Ne pas envoyer de fichiers confidentiels.',
}, },
en: { en: {
// Sidebar — 0.6.0 doc-centric nav (#209)
'nav.home': 'Home', 'nav.home': 'Home',
'nav.docs': 'Docs',
'nav.stores': 'Stores',
'nav.runs': 'Runs',
'nav.settings': 'Settings',
'nav.collapse': 'Collapse sidebar',
'nav.expand': 'Expand sidebar',
// Legacy nav labels — kept because legacy pages still use them.
'nav.studio': 'Studio', 'nav.studio': 'Studio',
'nav.documents': 'Documents', 'nav.documents': 'Documents',
'nav.history': 'History', 'nav.history': 'History',
'nav.reasoning': 'Reasoning', 'nav.reasoning': 'Reasoning',
'nav.settings': 'Settings', 'nav.search': 'Search',
'nav.collapse': 'Collapse sidebar',
'nav.expand': 'Expand sidebar',
'topbar.newAnalysis': 'New analysis', 'topbar.newAnalysis': 'New analysis',
@ -537,7 +549,6 @@ const messages: Messages = {
'chunking.batchNotice': 'chunking.batchNotice':
'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking. Coming soon!', 'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking. Coming soon!',
'nav.search': 'Search',
'search.hint': 'Enter a term to search through indexed chunks.', 'search.hint': 'Enter a term to search through indexed chunks.',
'ingestion.ingest': 'Ingest', 'ingestion.ingest': 'Ingest',

View file

@ -2,113 +2,15 @@
<aside class="sidebar" data-e2e="sidebar" :class="{ open }"> <aside class="sidebar" data-e2e="sidebar" :class="{ open }">
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<RouterLink <RouterLink
to="/" v-for="item in items"
:key="item.key"
:to="item.to"
class="nav-item" class="nav-item"
data-e2e="nav-home" :class="{ active: isActive(item), 'nav-item--primary': item.primary }"
:class="{ active: route.name === 'home' }" :data-e2e="`nav-${item.key}`"
> >
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"> <component :is="item.icon" class="nav-icon" />
<path <span class="nav-label">{{ t(item.labelKey) }}</span>
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
/>
</svg>
<span class="nav-label">{{ t('nav.home') }}</span>
</RouterLink>
<RouterLink
to="/studio"
class="nav-item"
data-e2e="nav-studio"
:class="{ active: route.name === 'studio' }"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"
/>
</svg>
<span class="nav-label">{{ t('nav.studio') }}</span>
</RouterLink>
<RouterLink
to="/documents"
class="nav-item"
data-e2e="nav-documents"
:class="{ active: route.name === 'documents' }"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
clip-rule="evenodd"
/>
</svg>
<span class="nav-label">{{ t('nav.documents') }}</span>
</RouterLink>
<RouterLink
v-if="ingestionEnabled"
to="/search"
class="nav-item"
data-e2e="nav-search"
:class="{ active: route.name === 'search' }"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd"
/>
</svg>
<span class="nav-label">{{ t('nav.search') }}</span>
</RouterLink>
<RouterLink
v-if="reasoningEnabled"
to="/reasoning"
class="nav-item"
data-e2e="nav-reasoning"
:class="{
active: route.name === 'reasoning' || route.name === 'reasoning-doc',
}"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
d="M10 2a5 5 0 00-5 5c0 1.72.87 3.24 2.2 4.14.47.32.8.84.8 1.4V14a1 1 0 001 1h2a1 1 0 001-1v-1.46c0-.56.33-1.08.8-1.4A5 5 0 0010 2zM8 17a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z"
/>
</svg>
<span class="nav-label">{{ t('nav.reasoning') }}</span>
</RouterLink>
<RouterLink
to="/history"
class="nav-item"
data-e2e="nav-history"
:class="{ active: route.name === 'history' }"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
clip-rule="evenodd"
/>
</svg>
<span class="nav-label">{{ t('nav.history') }}</span>
</RouterLink>
<RouterLink
to="/settings"
class="nav-item"
data-e2e="nav-settings"
:class="{ active: route.name === 'settings' }"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
clip-rule="evenodd"
/>
</svg>
<span class="nav-label">{{ t('nav.settings') }}</span>
</RouterLink> </RouterLink>
</nav> </nav>
@ -146,16 +48,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onBeforeUnmount } from 'vue' import { computed, h, onMounted, onBeforeUnmount, type Component } from 'vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from '../i18n' import type { RouteLocationRaw } from 'vue-router'
import { useFeatureFlagStore } from '../../features/feature-flags/store' import { useFeatureFlagStore } from '../../features/feature-flags/store'
import { useIngestionStore } from '../../features/ingestion/store' import { useIngestionStore } from '../../features/ingestion/store'
import { useI18n } from '../i18n'
import { ROUTES } from '../routing/names'
import { matchesActive } from './navActive'
const featureStore = useFeatureFlagStore() const featureStore = useFeatureFlagStore()
const ingestionStore = useIngestionStore() const ingestionStore = useIngestionStore()
const ingestionEnabled = computed(() => featureStore.isEnabled('ingestion')) const ingestionEnabled = computed(() => featureStore.isEnabled('ingestion'))
const reasoningEnabled = computed(() => featureStore.isEnabled('reasoning'))
const version = computed(() => featureStore.appVersion) const version = computed(() => featureStore.appVersion)
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
@ -164,6 +69,103 @@ defineProps({
open: { type: Boolean, default: false }, open: { type: Boolean, default: false },
}) })
// Inline SVG icon components keeping them here avoids dragging in an
// icon lib and keeps the visual weight close to the previous sidebar.
const HomeIcon: Component = () =>
h('svg', { viewBox: '0 0 20 20', fill: 'currentColor' }, [
h('path', {
d: 'M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z',
}),
])
const DocsIcon: Component = () =>
h('svg', { viewBox: '0 0 20 20', fill: 'currentColor' }, [
h('path', {
'fill-rule': 'evenodd',
'clip-rule': 'evenodd',
d: 'M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z',
}),
])
const StoresIcon: Component = () =>
h('svg', { viewBox: '0 0 20 20', fill: 'currentColor' }, [
h('path', {
'fill-rule': 'evenodd',
'clip-rule': 'evenodd',
d: 'M4 3a1 1 0 011-1h10a1 1 0 011 1v3a1 1 0 01-.293.707l-1.414 1.414a1 1 0 010 1.414l1.414 1.414A1 1 0 0116 11.5V14a1 1 0 01-1 1H5a1 1 0 01-1-1v-2.5a1 1 0 01.293-.707L5.707 9.5 4.293 8.086A1 1 0 014 7.379V3zm2 1v2.879l1.414 1.414a1 1 0 010 1.414L6 11.121V13h8v-1.879l-1.414-1.414a1 1 0 010-1.414L14 6.879V4H6z',
}),
])
const RunsIcon: Component = () =>
h('svg', { viewBox: '0 0 20 20', fill: 'currentColor' }, [
h('path', {
'fill-rule': 'evenodd',
'clip-rule': 'evenodd',
d: 'M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z',
}),
])
const SettingsIcon: Component = () =>
h('svg', { viewBox: '0 0 20 20', fill: 'currentColor' }, [
h('path', {
'fill-rule': 'evenodd',
'clip-rule': 'evenodd',
d: 'M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z',
}),
])
type NavItem = {
key: string
to: RouteLocationRaw
labelKey: string
icon: Component
primary?: boolean
matchPrefixes: readonly string[]
}
const items: NavItem[] = [
{
key: 'home',
to: { name: ROUTES.HOME },
labelKey: 'nav.home',
icon: HomeIcon,
matchPrefixes: ['/'],
},
{
key: 'docs',
to: { name: ROUTES.DOCS_LIBRARY },
labelKey: 'nav.docs',
icon: DocsIcon,
primary: true,
matchPrefixes: ['/docs'],
},
{
key: 'stores',
to: { name: ROUTES.STORES_LIST },
labelKey: 'nav.stores',
icon: StoresIcon,
matchPrefixes: ['/index'],
},
{
key: 'runs',
to: { name: ROUTES.RUNS },
labelKey: 'nav.runs',
icon: RunsIcon,
matchPrefixes: ['/runs'],
},
{
key: 'settings',
to: { name: ROUTES.SETTINGS },
labelKey: 'nav.settings',
icon: SettingsIcon,
matchPrefixes: ['/settings'],
},
]
function isActive(item: NavItem): boolean {
return matchesActive(route.path, item.matchPrefixes)
}
onMounted(() => { onMounted(() => {
if (ingestionEnabled.value) { if (ingestionEnabled.value) {
ingestionStore.checkAvailability() ingestionStore.checkAvailability()
@ -230,6 +232,14 @@ onBeforeUnmount(() => {
color: var(--accent); color: var(--accent);
} }
.nav-item--primary .nav-label {
font-weight: 600;
}
.nav-item--primary:not(.active) {
color: var(--text);
}
.nav-icon { .nav-icon {
width: 18px; width: 18px;
height: 18px; height: 18px;

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { matchesActive } from './navActive'
describe('matchesActive', () => {
it('matches Home only on the exact / path', () => {
expect(matchesActive('/', ['/'])).toBe(true)
expect(matchesActive('/docs', ['/'])).toBe(false)
expect(matchesActive('/anything', ['/'])).toBe(false)
})
it('matches a non-root prefix on exact, segment, and query boundaries', () => {
expect(matchesActive('/docs', ['/docs'])).toBe(true)
expect(matchesActive('/docs/abc', ['/docs'])).toBe(true)
expect(matchesActive('/docs/abc/123', ['/docs'])).toBe(true)
expect(matchesActive('/docs?foo=bar', ['/docs'])).toBe(true)
})
it('does not match prefixes that are part of a longer segment', () => {
expect(matchesActive('/documents', ['/docs'])).toBe(false)
expect(matchesActive('/docsy', ['/docs'])).toBe(false)
})
it('returns true if any prefix in the list matches', () => {
expect(matchesActive('/runs/abc', ['/index', '/runs'])).toBe(true)
expect(matchesActive('/index/foo/query', ['/index', '/runs'])).toBe(true)
expect(matchesActive('/somewhere-else', ['/index', '/runs'])).toBe(false)
})
it('returns false on empty prefix list', () => {
expect(matchesActive('/docs', [])).toBe(false)
})
})

View file

@ -0,0 +1,22 @@
/**
* Active-state matching for the sidebar.
*
* `Home` is active only on the exact `/` route otherwise visiting
* any sub-page would also light up Home. For every other entry, a
* prefix match is used so `/docs/abc?mode=chunks` highlights `Docs`.
*
* Pure helper so the component file stays tiny and the rule is
* unit-testable.
*/
export function matchesActive(path: string, prefixes: readonly string[]): boolean {
for (const prefix of prefixes) {
if (prefix === '/') {
if (path === '/') return true
continue
}
if (path === prefix || path.startsWith(prefix + '/') || path.startsWith(prefix + '?')) {
return true
}
}
return false
}