feat(#210): feature-flag mode gating + deep-link redirect
Backend - HealthResponse exposes inspectModeEnabled / chunksModeEnabled / askModeEnabled (additive; defaults true). main.py /api/health populates them from settings. - infra/settings.py: three new env-var-driven booleans (defaults true) parsed in from_env() like the existing reasoning_enabled flag. - tests/test_api_endpoints.py: extra assertion that /api/health surfaces the three new fields with their defaults. Frontend — flag store - features/feature-flags/store.ts: FeatureFlag union extended with inspectMode / chunksMode / askMode. New entries in featureRegistry are gated on context fields populated from health. Missing fields fall back to true so a frontend pointed at an older backend keeps every mode visible. - store gains a modeFlags() helper returning Record<DocMode, boolean> so the routing guard does not need to know the FeatureFlag union. Frontend — routing - shared/routing/resolveMode.ts: pure resolver. If the requested mode is enabled, return it; else first enabled in priority ask > chunks > inspect; else null. - app/router/index.ts: beforeEach guard scoped to ROUTES.DOC_WORKSPACE. Disabled mode → rewrite ?mode= to the first enabled one. All three off → redirect to /docs?reason=no-mode-enabled. Frontend — flash - pages/DocsLibraryPage.vue: shows a banner when ?reason=no-mode-enabled is set. #211 will move this into the proper library page banner. - i18n flags.allModesDisabled added in fr + en. Tests - shared/routing/resolveMode.test.ts (6 cases): every (requested, enabled) combination including all-disabled, priority order, missing requested. - features/feature-flags/store.test.ts: three new cases covering the new fields in /api/health, fall-back-to-true on missing fields, and modeFlags() shape. Refs #210
This commit is contained in:
parent
ef98464b68
commit
7351159eeb
11 changed files with 270 additions and 6 deletions
|
|
@ -46,6 +46,12 @@ class HealthResponse(_CamelModel):
|
||||||
# available: REASONING_ENABLED=true AND deps importable. Doesn't imply
|
# available: REASONING_ENABLED=true AND deps importable. Doesn't imply
|
||||||
# Ollama itself is reachable — that's checked per-call.
|
# Ollama itself is reachable — that's checked per-call.
|
||||||
reasoning_available: bool = False
|
reasoning_available: bool = False
|
||||||
|
# 0.6.0 — Doc workspace mode flags (#210). Default true so existing
|
||||||
|
# frontends without the new keys (legacy backend image rolling forward)
|
||||||
|
# see the same behaviour they had.
|
||||||
|
inspect_mode_enabled: bool = True
|
||||||
|
chunks_mode_enabled: bool = True
|
||||||
|
ask_mode_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class DocumentResponse(_CamelModel):
|
class DocumentResponse(_CamelModel):
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,12 @@ class Settings:
|
||||||
cors_origins: list[str] = field(
|
cors_origins: list[str] = field(
|
||||||
default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"]
|
default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"]
|
||||||
)
|
)
|
||||||
|
# 0.6.0 — Doc workspace mode flags (#210). All on by default to preserve
|
||||||
|
# existing behaviour; operators flip a flag off to hide a mode tab + redirect
|
||||||
|
# deep links. Per-tenant gating is out of scope for 0.6.0.
|
||||||
|
inspect_mode_enabled: bool = True
|
||||||
|
chunks_mode_enabled: bool = True
|
||||||
|
ask_mode_enabled: bool = True
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
@ -153,6 +159,13 @@ class Settings:
|
||||||
max_paste_image_size_mb=int(os.environ.get("MAX_PASTE_IMAGE_SIZE_MB", "10")),
|
max_paste_image_size_mb=int(os.environ.get("MAX_PASTE_IMAGE_SIZE_MB", "10")),
|
||||||
paste_allowed_image_types=[t.strip() for t in paste_types_raw.split(",") if t.strip()],
|
paste_allowed_image_types=[t.strip() for t in paste_types_raw.split(",") if t.strip()],
|
||||||
cors_origins=[o.strip() for o in cors_raw.split(",")],
|
cors_origins=[o.strip() for o in cors_raw.split(",")],
|
||||||
|
# 0.6.0 — Doc workspace mode flags (#210). Defaults: enabled.
|
||||||
|
inspect_mode_enabled=os.environ.get("INSPECT_MODE_ENABLED", "true").lower()
|
||||||
|
in ("1", "true", "yes", "on"),
|
||||||
|
chunks_mode_enabled=os.environ.get("CHUNKS_MODE_ENABLED", "true").lower()
|
||||||
|
in ("1", "true", "yes", "on"),
|
||||||
|
ask_mode_enabled=os.environ.get("ASK_MODE_ENABLED", "true").lower()
|
||||||
|
in ("1", "true", "yes", "on"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -304,4 +304,8 @@ async def health() -> HealthResponse:
|
||||||
# actual Ollama reachability is checked lazily at call-time to avoid
|
# actual Ollama reachability is checked lazily at call-time to avoid
|
||||||
# blocking health checks on the LLM host.
|
# blocking health checks on the LLM host.
|
||||||
reasoning_available=runner is not None and runner.is_available,
|
reasoning_available=runner is not None and runner.is_available,
|
||||||
|
# 0.6.0 — Doc workspace mode flags (#210).
|
||||||
|
inspect_mode_enabled=settings.inspect_mode_enabled,
|
||||||
|
chunks_mode_enabled=settings.chunks_mode_enabled,
|
||||||
|
ask_mode_enabled=settings.ask_mode_enabled,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,18 @@ class TestHealthEndpoint:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["ingestionAvailable"] is True
|
assert data["ingestionAvailable"] is True
|
||||||
|
|
||||||
|
def test_health_exposes_doc_mode_flags(self, client):
|
||||||
|
"""0.6.0 (#210): /api/health surfaces inspect/chunks/ask mode flags."""
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
data = resp.json()
|
||||||
|
assert "inspectModeEnabled" in data
|
||||||
|
assert "chunksModeEnabled" in data
|
||||||
|
assert "askModeEnabled" in data
|
||||||
|
# Defaults preserve current behaviour (all enabled).
|
||||||
|
assert data["inspectModeEnabled"] is True
|
||||||
|
assert data["chunksModeEnabled"] is True
|
||||||
|
assert data["askModeEnabled"] is True
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentEndpoints:
|
class TestDocumentEndpoints:
|
||||||
def test_list_documents(self, client, mock_document_service):
|
def test_list_documents(self, client, mock_document_service):
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import { useFeatureFlagStore } from '../../features/feature-flags/store'
|
||||||
|
import { isDocMode } from '../../shared/routing/modes'
|
||||||
|
import { ROUTES } from '../../shared/routing/names'
|
||||||
|
import { resolveMode } from '../../shared/routing/resolveMode'
|
||||||
import { routes } from './routes'
|
import { routes } from './routes'
|
||||||
|
|
||||||
export { routes }
|
export { routes }
|
||||||
|
|
@ -8,3 +12,37 @@ export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doc workspace mode guard (#210).
|
||||||
|
*
|
||||||
|
* - `/docs/:id?mode=<disabled>` → redirect with the same id but the
|
||||||
|
* first enabled mode (ask > chunks > inspect priority).
|
||||||
|
* - All three modes off → redirect to `/docs?reason=no-mode-enabled`.
|
||||||
|
* - Any other route is left untouched.
|
||||||
|
*
|
||||||
|
* Pure resolution lives in `resolveMode`; the guard is just the
|
||||||
|
* router-level wiring.
|
||||||
|
*/
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.name !== ROUTES.DOC_WORKSPACE) return true
|
||||||
|
|
||||||
|
const flags = useFeatureFlagStore().modeFlags()
|
||||||
|
const requestedRaw = Array.isArray(to.query.mode) ? to.query.mode[0] : to.query.mode
|
||||||
|
const requested = isDocMode(requestedRaw) ? requestedRaw : undefined
|
||||||
|
const resolved = resolveMode(requested, flags)
|
||||||
|
|
||||||
|
if (resolved === null) {
|
||||||
|
return {
|
||||||
|
name: ROUTES.DOCS_LIBRARY,
|
||||||
|
query: { reason: 'no-mode-enabled' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolved !== requested) {
|
||||||
|
return {
|
||||||
|
...to,
|
||||||
|
query: { ...to.query, mode: resolved },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -155,4 +155,42 @@ describe('useFeatureFlagStore', () => {
|
||||||
expect(store.isEnabled('chunking')).toBe(false)
|
expect(store.isEnabled('chunking')).toBe(false)
|
||||||
expect(store.isEnabled('disclaimer')).toBe(false)
|
expect(store.isEnabled('disclaimer')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 0.6.0 — Doc workspace mode flags (#210).
|
||||||
|
it('exposes inspectMode / chunksMode / askMode flags from /api/health', async () => {
|
||||||
|
mockApiFetch.mockResolvedValue({
|
||||||
|
status: 'ok',
|
||||||
|
engine: 'local',
|
||||||
|
inspectModeEnabled: false,
|
||||||
|
chunksModeEnabled: true,
|
||||||
|
askModeEnabled: true,
|
||||||
|
})
|
||||||
|
const store = useFeatureFlagStore()
|
||||||
|
await store.load()
|
||||||
|
expect(store.isEnabled('inspectMode')).toBe(false)
|
||||||
|
expect(store.isEnabled('chunksMode')).toBe(true)
|
||||||
|
expect(store.isEnabled('askMode')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to all-modes-enabled when /api/health omits the new fields', async () => {
|
||||||
|
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local' })
|
||||||
|
const store = useFeatureFlagStore()
|
||||||
|
await store.load()
|
||||||
|
expect(store.isEnabled('inspectMode')).toBe(true)
|
||||||
|
expect(store.isEnabled('chunksMode')).toBe(true)
|
||||||
|
expect(store.isEnabled('askMode')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modeFlags() returns the three flags in a Record<DocMode, boolean>', async () => {
|
||||||
|
mockApiFetch.mockResolvedValue({
|
||||||
|
status: 'ok',
|
||||||
|
engine: 'local',
|
||||||
|
inspectModeEnabled: true,
|
||||||
|
chunksModeEnabled: false,
|
||||||
|
askModeEnabled: true,
|
||||||
|
})
|
||||||
|
const store = useFeatureFlagStore()
|
||||||
|
await store.load()
|
||||||
|
expect(store.modeFlags()).toEqual({ ask: true, chunks: false, inspect: true })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,21 @@ interface HealthResponse {
|
||||||
maxFileSizeMb?: number
|
maxFileSizeMb?: number
|
||||||
ingestionAvailable?: boolean
|
ingestionAvailable?: boolean
|
||||||
reasoningAvailable?: boolean
|
reasoningAvailable?: boolean
|
||||||
|
// 0.6.0 — Doc workspace mode flags (#210). Optional so an older backend
|
||||||
|
// image without these fields keeps working: missing → fall back to true.
|
||||||
|
inspectModeEnabled?: boolean
|
||||||
|
chunksModeEnabled?: boolean
|
||||||
|
askModeEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeatureFlag = 'chunking' | 'disclaimer' | 'ingestion' | 'reasoning'
|
export type FeatureFlag =
|
||||||
|
| 'chunking'
|
||||||
|
| 'disclaimer'
|
||||||
|
| 'ingestion'
|
||||||
|
| 'reasoning'
|
||||||
|
| 'inspectMode'
|
||||||
|
| 'chunksMode'
|
||||||
|
| 'askMode'
|
||||||
|
|
||||||
interface FeatureFlagDef {
|
interface FeatureFlagDef {
|
||||||
description: string
|
description: string
|
||||||
|
|
@ -29,6 +41,9 @@ interface FeatureFlagContext {
|
||||||
deploymentMode: DeploymentMode | null
|
deploymentMode: DeploymentMode | null
|
||||||
ingestionAvailable: boolean
|
ingestionAvailable: boolean
|
||||||
reasoningAvailable: boolean
|
reasoningAvailable: boolean
|
||||||
|
inspectModeEnabled: boolean
|
||||||
|
chunksModeEnabled: boolean
|
||||||
|
askModeEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
|
const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
|
||||||
|
|
@ -52,6 +67,21 @@ const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
|
||||||
description: 'Reasoning trace tunnel (docling-agent ReasoningResult viewer)',
|
description: 'Reasoning trace tunnel (docling-agent ReasoningResult viewer)',
|
||||||
isEnabled: (ctx) => ctx.reasoningAvailable,
|
isEnabled: (ctx) => ctx.reasoningAvailable,
|
||||||
},
|
},
|
||||||
|
// 0.6.0 — Doc workspace mode flags (#210). Each one gates a tab in the
|
||||||
|
// doc workspace (#216 / E4) and triggers a router-level redirect when a
|
||||||
|
// disabled mode is requested via deep link. Defaults: enabled.
|
||||||
|
inspectMode: {
|
||||||
|
description: 'Doc workspace Inspect mode (tree + bbox debug view)',
|
||||||
|
isEnabled: (ctx) => ctx.inspectModeEnabled,
|
||||||
|
},
|
||||||
|
chunksMode: {
|
||||||
|
description: 'Doc workspace Chunks mode (editable chunkset + push to store)',
|
||||||
|
isEnabled: (ctx) => ctx.chunksModeEnabled,
|
||||||
|
},
|
||||||
|
askMode: {
|
||||||
|
description: 'Doc workspace Ask mode (agentic reasoning over the doc)',
|
||||||
|
isEnabled: (ctx) => ctx.askModeEnabled,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
||||||
|
|
@ -61,6 +91,11 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
||||||
const maxFileSizeMb = ref<number>(0)
|
const maxFileSizeMb = ref<number>(0)
|
||||||
const ingestionAvailable = ref(false)
|
const ingestionAvailable = ref(false)
|
||||||
const reasoningAvailable = ref(false)
|
const reasoningAvailable = ref(false)
|
||||||
|
// 0.6.0 — Doc workspace mode flags (#210). Default true so a backend
|
||||||
|
// that hasn't shipped the new fields yet behaves like the legacy one.
|
||||||
|
const inspectModeEnabled = ref(true)
|
||||||
|
const chunksModeEnabled = ref(true)
|
||||||
|
const askModeEnabled = ref(true)
|
||||||
const appVersion = ref<string>(__APP_VERSION__)
|
const appVersion = ref<string>(__APP_VERSION__)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
@ -70,6 +105,9 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
||||||
deploymentMode: deploymentMode.value,
|
deploymentMode: deploymentMode.value,
|
||||||
ingestionAvailable: ingestionAvailable.value,
|
ingestionAvailable: ingestionAvailable.value,
|
||||||
reasoningAvailable: reasoningAvailable.value,
|
reasoningAvailable: reasoningAvailable.value,
|
||||||
|
inspectModeEnabled: inspectModeEnabled.value,
|
||||||
|
chunksModeEnabled: chunksModeEnabled.value,
|
||||||
|
askModeEnabled: askModeEnabled.value,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function isEnabled(flag: FeatureFlag): boolean {
|
function isEnabled(flag: FeatureFlag): boolean {
|
||||||
|
|
@ -87,6 +125,11 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
||||||
maxFileSizeMb.value = data.maxFileSizeMb ?? 0
|
maxFileSizeMb.value = data.maxFileSizeMb ?? 0
|
||||||
ingestionAvailable.value = data.ingestionAvailable ?? false
|
ingestionAvailable.value = data.ingestionAvailable ?? false
|
||||||
reasoningAvailable.value = data.reasoningAvailable ?? false
|
reasoningAvailable.value = data.reasoningAvailable ?? false
|
||||||
|
// 0.6.0 — fall back to true when the field is missing so a frontend
|
||||||
|
// pointed at an older backend keeps every mode visible.
|
||||||
|
inspectModeEnabled.value = data.inspectModeEnabled ?? true
|
||||||
|
chunksModeEnabled.value = data.chunksModeEnabled ?? true
|
||||||
|
askModeEnabled.value = data.askModeEnabled ?? true
|
||||||
appMaxFileSizeMb.value = maxFileSizeMb.value
|
appMaxFileSizeMb.value = maxFileSizeMb.value
|
||||||
appMaxPageCount.value = maxPageCount.value
|
appMaxPageCount.value = maxPageCount.value
|
||||||
if (data.version) appVersion.value = data.version
|
if (data.version) appVersion.value = data.version
|
||||||
|
|
@ -98,6 +141,19 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience accessor for `resolveMode` — returns the three doc
|
||||||
|
* workspace mode flags as a `Record<DocMode, boolean>` so the routing
|
||||||
|
* guard does not need to know about the FeatureFlag union.
|
||||||
|
*/
|
||||||
|
function modeFlags(): { ask: boolean; inspect: boolean; chunks: boolean } {
|
||||||
|
return {
|
||||||
|
ask: askModeEnabled.value,
|
||||||
|
inspect: inspectModeEnabled.value,
|
||||||
|
chunks: chunksModeEnabled.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
engine,
|
engine,
|
||||||
deploymentMode,
|
deploymentMode,
|
||||||
|
|
@ -105,10 +161,14 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
||||||
maxFileSizeMb,
|
maxFileSizeMb,
|
||||||
ingestionAvailable,
|
ingestionAvailable,
|
||||||
reasoningAvailable,
|
reasoningAvailable,
|
||||||
|
inspectModeEnabled,
|
||||||
|
chunksModeEnabled,
|
||||||
|
askModeEnabled,
|
||||||
appVersion,
|
appVersion,
|
||||||
loaded,
|
loaded,
|
||||||
error,
|
error,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
|
modeFlags,
|
||||||
load,
|
load,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,39 @@
|
||||||
<template>
|
<template>
|
||||||
<ComingSoonShell
|
<div>
|
||||||
:title="t('comingSoon.title')"
|
<div v-if="showFlashAllModesDisabled" class="flash flash--warning" role="alert">
|
||||||
:subtitle="t('comingSoon.subtitle.docsLibrary')"
|
{{ t('flags.allModesDisabled') }}
|
||||||
/>
|
</div>
|
||||||
|
<ComingSoonShell
|
||||||
|
:title="t('comingSoon.title')"
|
||||||
|
:subtitle="t('comingSoon.subtitle.docsLibrary')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from '../shared/i18n'
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { useI18n } from '../shared/i18n'
|
||||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// Surfaced when the router redirected here because every doc workspace
|
||||||
|
// mode is feature-flagged off (#210). #211 will replace this with a
|
||||||
|
// proper banner inside the library page.
|
||||||
|
const showFlashAllModesDisabled = computed(() => route.query.reason === 'no-mode-enabled')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flash {
|
||||||
|
margin: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #92400e;
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ const messages: Messages = {
|
||||||
'breadcrumb.mode.inspect': 'Inspect',
|
'breadcrumb.mode.inspect': 'Inspect',
|
||||||
'breadcrumb.mode.chunks': 'Chunks',
|
'breadcrumb.mode.chunks': 'Chunks',
|
||||||
|
|
||||||
|
// Feature flags (0.6.0 — #210)
|
||||||
|
'flags.allModesDisabled':
|
||||||
|
"Aucun mode (Ask / Inspect / Chunks) n'est activé pour ce déploiement. Contactez votre administrateur.",
|
||||||
|
|
||||||
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
|
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
|
||||||
'comingSoon.title': 'Bientôt disponible',
|
'comingSoon.title': 'Bientôt disponible',
|
||||||
'comingSoon.subtitle.docsLibrary':
|
'comingSoon.subtitle.docsLibrary':
|
||||||
|
|
@ -333,6 +337,10 @@ const messages: Messages = {
|
||||||
'breadcrumb.mode.inspect': 'Inspect',
|
'breadcrumb.mode.inspect': 'Inspect',
|
||||||
'breadcrumb.mode.chunks': 'Chunks',
|
'breadcrumb.mode.chunks': 'Chunks',
|
||||||
|
|
||||||
|
// Feature flags (0.6.0 — #210)
|
||||||
|
'flags.allModesDisabled':
|
||||||
|
'No doc workspace mode (Ask / Inspect / Chunks) is enabled for this deployment. Contact your administrator.',
|
||||||
|
|
||||||
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
|
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
|
||||||
'comingSoon.title': 'Coming soon',
|
'comingSoon.title': 'Coming soon',
|
||||||
'comingSoon.subtitle.docsLibrary':
|
'comingSoon.subtitle.docsLibrary':
|
||||||
|
|
|
||||||
39
frontend/src/shared/routing/resolveMode.test.ts
Normal file
39
frontend/src/shared/routing/resolveMode.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { DocMode } from './modes'
|
||||||
|
import { MODE_PRIORITY, resolveMode } from './resolveMode'
|
||||||
|
|
||||||
|
const allEnabled: Record<DocMode, boolean> = { ask: true, chunks: true, inspect: true }
|
||||||
|
const allDisabled: Record<DocMode, boolean> = { ask: false, chunks: false, inspect: false }
|
||||||
|
|
||||||
|
describe('resolveMode', () => {
|
||||||
|
it('returns the requested mode when it is enabled', () => {
|
||||||
|
expect(resolveMode('ask', allEnabled)).toBe('ask')
|
||||||
|
expect(resolveMode('chunks', allEnabled)).toBe('chunks')
|
||||||
|
expect(resolveMode('inspect', allEnabled)).toBe('inspect')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the highest-priority enabled mode when the requested one is disabled', () => {
|
||||||
|
expect(resolveMode('chunks', { ...allEnabled, chunks: false })).toBe('ask')
|
||||||
|
expect(resolveMode('chunks', { ask: false, chunks: false, inspect: true })).toBe('inspect')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours the priority order ask > chunks > inspect', () => {
|
||||||
|
expect(resolveMode(undefined, allEnabled)).toBe('ask')
|
||||||
|
expect(resolveMode(undefined, { ask: false, chunks: true, inspect: true })).toBe('chunks')
|
||||||
|
expect(resolveMode(undefined, { ask: false, chunks: false, inspect: true })).toBe('inspect')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when no mode is enabled', () => {
|
||||||
|
expect(resolveMode('ask', allDisabled)).toBeNull()
|
||||||
|
expect(resolveMode(undefined, allDisabled)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles missing requested gracefully', () => {
|
||||||
|
expect(resolveMode(undefined, allEnabled)).toBe('ask')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes the priority in the right order', () => {
|
||||||
|
expect(MODE_PRIORITY).toEqual(['ask', 'chunks', 'inspect'])
|
||||||
|
})
|
||||||
|
})
|
||||||
21
frontend/src/shared/routing/resolveMode.ts
Normal file
21
frontend/src/shared/routing/resolveMode.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { type DocMode } from './modes'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doc workspace mode resolution under feature flags (#210).
|
||||||
|
*
|
||||||
|
* The router consults this when a user opens `/docs/:id?mode=<mode>`:
|
||||||
|
*
|
||||||
|
* - If the requested mode is enabled, return it.
|
||||||
|
* - Otherwise, return the first enabled mode in `MODE_PRIORITY`.
|
||||||
|
* - If no mode is enabled, return `null` (the router redirects to
|
||||||
|
* the docs library with a flash message).
|
||||||
|
*/
|
||||||
|
export const MODE_PRIORITY: readonly DocMode[] = ['ask', 'chunks', 'inspect'] as const
|
||||||
|
|
||||||
|
export function resolveMode(
|
||||||
|
requested: DocMode | undefined,
|
||||||
|
enabled: Record<DocMode, boolean>,
|
||||||
|
): DocMode | null {
|
||||||
|
if (requested && enabled[requested]) return requested
|
||||||
|
return MODE_PRIORITY.find((m) => enabled[m]) ?? null
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue