diff --git a/document-parser/api/schemas.py b/document-parser/api/schemas.py index 7d3d329..2cc9252 100644 --- a/document-parser/api/schemas.py +++ b/document-parser/api/schemas.py @@ -46,11 +46,14 @@ class HealthResponse(_CamelModel): # available: REASONING_ENABLED=true AND deps importable. Doesn't imply # Ollama itself is reachable — that's checked per-call. 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. + # 0.6.1 — Surface flags (#257). Master flags select which surface(s) + # the frontend exposes. Defaults match the production target (RAG only). + studio_mode_enabled: bool = False + rag_pipeline_enabled: bool = True + # 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). Default true + # so frontends pointed at an older backend keep every mode visible. inspect_mode_enabled: bool = True - chunks_mode_enabled: bool = True + linked_mode_enabled: bool = True ask_mode_enabled: bool = True diff --git a/document-parser/infra/settings.py b/document-parser/infra/settings.py index 6186168..ced0a07 100644 --- a/document-parser/infra/settings.py +++ b/document-parser/infra/settings.py @@ -55,11 +55,17 @@ class Settings: cors_origins: list[str] = field( 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. + # 0.6.1 — Surface flags (#257). Two master flags select which UI surface + # is exposed: STUDIO_MODE_ENABLED (legacy OCR-debug) and + # RAG_PIPELINE_ENABLED (new doc-centric ingestion + visualization). + # At least one must be enabled. Sub-flags below gate individual modes + # inside the RAG pipeline surface. + studio_mode_enabled: bool = False + rag_pipeline_enabled: bool = True + # 0.6.0 — Doc workspace mode flags (#210, renamed in #257). + # Sub-flags effective only when rag_pipeline_enabled is true. inspect_mode_enabled: bool = True - chunks_mode_enabled: bool = True + linked_mode_enabled: bool = True ask_mode_enabled: bool = True def __post_init__(self) -> None: @@ -96,6 +102,8 @@ class Settings: ) if self.embedding_dimension < 1: errors.append(f"embedding_dimension must be >= 1 (got {self.embedding_dimension})") + if not (self.studio_mode_enabled or self.rag_pipeline_enabled): + errors.append("at least one of STUDIO_MODE_ENABLED / RAG_PIPELINE_ENABLED must be true") if self.default_table_mode not in ("accurate", "fast"): errors.append( f"default_table_mode must be 'accurate' or 'fast' (got '{self.default_table_mode}')" @@ -159,10 +167,15 @@ class Settings: 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()], cors_origins=[o.strip() for o in cors_raw.split(",")], - # 0.6.0 — Doc workspace mode flags (#210). Defaults: enabled. + # 0.6.1 — Surface flags (#257). + studio_mode_enabled=os.environ.get("STUDIO_MODE_ENABLED", "false").lower() + in ("1", "true", "yes", "on"), + rag_pipeline_enabled=os.environ.get("RAG_PIPELINE_ENABLED", "true").lower() + in ("1", "true", "yes", "on"), + # 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). 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() + linked_mode_enabled=os.environ.get("LINKED_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"), diff --git a/document-parser/main.py b/document-parser/main.py index ce00bdd..0008dea 100644 --- a/document-parser/main.py +++ b/document-parser/main.py @@ -343,8 +343,11 @@ async def health() -> HealthResponse: # actual Ollama reachability is checked lazily at call-time to avoid # blocking health checks on the LLM host. reasoning_available=runner is not None and runner.is_available, - # 0.6.0 — Doc workspace mode flags (#210). + # 0.6.1 — Surface flags (#257). + studio_mode_enabled=settings.studio_mode_enabled, + rag_pipeline_enabled=settings.rag_pipeline_enabled, + # 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). inspect_mode_enabled=settings.inspect_mode_enabled, - chunks_mode_enabled=settings.chunks_mode_enabled, + linked_mode_enabled=settings.linked_mode_enabled, ask_mode_enabled=settings.ask_mode_enabled, ) diff --git a/document-parser/tests/test_api_endpoints.py b/document-parser/tests/test_api_endpoints.py index 1b43cf4..46851ed 100644 --- a/document-parser/tests/test_api_endpoints.py +++ b/document-parser/tests/test_api_endpoints.py @@ -68,16 +68,26 @@ class TestHealthEndpoint: data = resp.json() assert data["ingestionAvailable"] is True + def test_health_exposes_surface_flags(self, client): + """0.6.1 (#257): /api/health surfaces studio + rag_pipeline master flags.""" + resp = client.get("/api/health") + data = resp.json() + assert "studioModeEnabled" in data + assert "ragPipelineEnabled" in data + # Defaults: studio off, rag pipeline on (production target). + assert data["studioModeEnabled"] is False + assert data["ragPipelineEnabled"] is True + def test_health_exposes_doc_mode_flags(self, client): - """0.6.0 (#210): /api/health surfaces inspect/chunks/ask mode flags.""" + """0.6.0 (#210, renamed in #257): /api/health surfaces inspect/linked/ask sub-flags.""" resp = client.get("/api/health") data = resp.json() assert "inspectModeEnabled" in data - assert "chunksModeEnabled" in data + assert "linkedModeEnabled" in data assert "askModeEnabled" in data - # Defaults preserve current behaviour (all enabled). + # Sub-flag defaults preserve current behaviour (all enabled). assert data["inspectModeEnabled"] is True - assert data["chunksModeEnabled"] is True + assert data["linkedModeEnabled"] is True assert data["askModeEnabled"] is True diff --git a/frontend/src/app/router/index.ts b/frontend/src/app/router/index.ts index d63c0fe..16ea104 100644 --- a/frontend/src/app/router/index.ts +++ b/frontend/src/app/router/index.ts @@ -4,6 +4,7 @@ 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 { resolveSurface } from '../../shared/routing/resolveSurface' import { routes } from './routes' export { routes } @@ -14,20 +15,29 @@ export const router = createRouter({ }) /** - * Doc workspace mode guard (#210). + * Surface gating (#257) + mode gating (#210). * - * - `/docs/:id?mode=` → 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. + * Surface: two master flags select which UI surface is exposed. When a + * route from a disabled surface is requested, redirect to the other + * surface's landing page — or to `/` if both are off (defensive net; + * backend refuses to start in that state). * - * Pure resolution lives in `resolveMode`; the guard is just the - * router-level wiring. + * Mode: inside the doc workspace, the requested mode falls back to the + * first enabled mode when the deep-linked one is off. */ router.beforeEach((to) => { + const flagStore = useFeatureFlagStore() + const name = String(to.name ?? '') + + const surfaceRedirect = resolveSurface(name, { + studio: flagStore.studioModeEnabled, + rag: flagStore.ragPipelineEnabled, + }) + if (surfaceRedirect !== null) return { name: surfaceRedirect } + if (to.name !== ROUTES.DOC_WORKSPACE) return true - const flags = useFeatureFlagStore().modeFlags() + const flags = flagStore.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) diff --git a/frontend/src/features/feature-flags/store.test.ts b/frontend/src/features/feature-flags/store.test.ts index 33193cc..f2bee9f 100644 --- a/frontend/src/features/feature-flags/store.test.ts +++ b/frontend/src/features/feature-flags/store.test.ts @@ -156,19 +156,58 @@ describe('useFeatureFlagStore', () => { expect(store.isEnabled('disclaimer')).toBe(false) }) - // 0.6.0 — Doc workspace mode flags (#210). - it('exposes inspectMode / chunksMode / askMode flags from /api/health', async () => { + // 0.6.1 — Surface master flags (#257). + it('exposes studioMode / ragPipeline master flags from /api/health', async () => { mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local', - inspectModeEnabled: false, - chunksModeEnabled: true, + studioModeEnabled: true, + ragPipelineEnabled: false, + }) + const store = useFeatureFlagStore() + await store.load() + expect(store.isEnabled('studioMode')).toBe(true) + expect(store.isEnabled('ragPipeline')).toBe(false) + }) + + it('defaults studio off, rag pipeline on when /api/health omits the fields', async () => { + mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local' }) + const store = useFeatureFlagStore() + await store.load() + expect(store.isEnabled('studioMode')).toBe(false) + expect(store.isEnabled('ragPipeline')).toBe(true) + }) + + it('sub-flags require ragPipeline enabled', async () => { + mockApiFetch.mockResolvedValue({ + status: 'ok', + engine: 'local', + ragPipelineEnabled: false, + inspectModeEnabled: true, + linkedModeEnabled: true, askModeEnabled: true, }) const store = useFeatureFlagStore() await store.load() expect(store.isEnabled('inspectMode')).toBe(false) - expect(store.isEnabled('chunksMode')).toBe(true) + expect(store.isEnabled('linkedMode')).toBe(false) + expect(store.isEnabled('askMode')).toBe(false) + }) + + // 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). + it('exposes inspectMode / linkedMode / askMode flags from /api/health', async () => { + mockApiFetch.mockResolvedValue({ + status: 'ok', + engine: 'local', + ragPipelineEnabled: true, + inspectModeEnabled: false, + linkedModeEnabled: true, + askModeEnabled: true, + }) + const store = useFeatureFlagStore() + await store.load() + expect(store.isEnabled('inspectMode')).toBe(false) + expect(store.isEnabled('linkedMode')).toBe(true) expect(store.isEnabled('askMode')).toBe(true) }) @@ -177,7 +216,7 @@ describe('useFeatureFlagStore', () => { const store = useFeatureFlagStore() await store.load() expect(store.isEnabled('inspectMode')).toBe(true) - expect(store.isEnabled('chunksMode')).toBe(true) + expect(store.isEnabled('linkedMode')).toBe(true) expect(store.isEnabled('askMode')).toBe(true) }) @@ -186,7 +225,7 @@ describe('useFeatureFlagStore', () => { status: 'ok', engine: 'local', inspectModeEnabled: true, - chunksModeEnabled: false, + linkedModeEnabled: false, askModeEnabled: true, }) const store = useFeatureFlagStore() diff --git a/frontend/src/features/feature-flags/store.ts b/frontend/src/features/feature-flags/store.ts index 653270b..fa83c05 100644 --- a/frontend/src/features/feature-flags/store.ts +++ b/frontend/src/features/feature-flags/store.ts @@ -15,10 +15,14 @@ interface HealthResponse { maxFileSizeMb?: number ingestionAvailable?: 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. + // 0.6.1 — Surface master flags (#257). Optional for backward compat: + // studio defaults to false (production target), rag pipeline to true. + studioModeEnabled?: boolean + ragPipelineEnabled?: boolean + // 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). Optional so an + // older backend image without these fields keeps working: missing → true. inspectModeEnabled?: boolean - chunksModeEnabled?: boolean + linkedModeEnabled?: boolean askModeEnabled?: boolean } @@ -27,8 +31,10 @@ export type FeatureFlag = | 'disclaimer' | 'ingestion' | 'reasoning' + | 'studioMode' + | 'ragPipeline' | 'inspectMode' - | 'chunksMode' + | 'linkedMode' | 'askMode' interface FeatureFlagDef { @@ -41,8 +47,10 @@ interface FeatureFlagContext { deploymentMode: DeploymentMode | null ingestionAvailable: boolean reasoningAvailable: boolean + studioModeEnabled: boolean + ragPipelineEnabled: boolean inspectModeEnabled: boolean - chunksModeEnabled: boolean + linkedModeEnabled: boolean askModeEnabled: boolean } @@ -67,20 +75,31 @@ const featureRegistry: Record = { description: 'Reasoning trace tunnel (docling-agent ReasoningResult viewer)', 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. + // 0.6.1 — Surface master flags (#257). Select which UI surface(s) are + // exposed; at least one must be on (validated server-side too). + studioMode: { + description: 'Legacy Studio surface (OCR debug, original 0.5.x UI)', + isEnabled: (ctx) => ctx.studioModeEnabled, + }, + ragPipeline: { + description: 'New doc-centric RAG ingestion + visualization pipeline', + isEnabled: (ctx) => ctx.ragPipelineEnabled, + }, + // 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). Each gates a + // mode inside the doc workspace and triggers a router-level redirect + // when a disabled mode is requested via deep link. Effective only when + // ragPipeline is enabled. inspectMode: { description: 'Doc workspace Inspect mode (tree + bbox debug view)', - isEnabled: (ctx) => ctx.inspectModeEnabled, + isEnabled: (ctx) => ctx.ragPipelineEnabled && ctx.inspectModeEnabled, }, - chunksMode: { - description: 'Doc workspace Chunks mode (editable chunkset + push to store)', - isEnabled: (ctx) => ctx.chunksModeEnabled, + linkedMode: { + description: 'Doc workspace Linked mode (preview + aligned chunks panel)', + isEnabled: (ctx) => ctx.ragPipelineEnabled && ctx.linkedModeEnabled, }, askMode: { description: 'Doc workspace Ask mode (agentic reasoning over the doc)', - isEnabled: (ctx) => ctx.askModeEnabled, + isEnabled: (ctx) => ctx.ragPipelineEnabled && ctx.askModeEnabled, }, } @@ -91,10 +110,14 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { const maxFileSizeMb = ref(0) const ingestionAvailable = 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. + // 0.6.1 — Surface master flags (#257). Defaults match production target: + // legacy Studio off, new RAG pipeline on. + const studioModeEnabled = ref(false) + const ragPipelineEnabled = ref(true) + // 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). Default true + // so a backend without these fields behaves like the legacy one. const inspectModeEnabled = ref(true) - const chunksModeEnabled = ref(true) + const linkedModeEnabled = ref(true) const askModeEnabled = ref(true) const appVersion = ref(__APP_VERSION__) const loaded = ref(false) @@ -105,8 +128,10 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { deploymentMode: deploymentMode.value, ingestionAvailable: ingestionAvailable.value, reasoningAvailable: reasoningAvailable.value, + studioModeEnabled: studioModeEnabled.value, + ragPipelineEnabled: ragPipelineEnabled.value, inspectModeEnabled: inspectModeEnabled.value, - chunksModeEnabled: chunksModeEnabled.value, + linkedModeEnabled: linkedModeEnabled.value, askModeEnabled: askModeEnabled.value, })) @@ -125,10 +150,14 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { maxFileSizeMb.value = data.maxFileSizeMb ?? 0 ingestionAvailable.value = data.ingestionAvailable ?? 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. + // 0.6.1 — surface flags. Backward compat: missing studio → false + // (production target), missing rag pipeline → true (legacy behaviour). + studioModeEnabled.value = data.studioModeEnabled ?? false + ragPipelineEnabled.value = data.ragPipelineEnabled ?? true + // Sub-flags: fall back to true so an older backend keeps every mode + // visible. inspectModeEnabled.value = data.inspectModeEnabled ?? true - chunksModeEnabled.value = data.chunksModeEnabled ?? true + linkedModeEnabled.value = data.linkedModeEnabled ?? true askModeEnabled.value = data.askModeEnabled ?? true appMaxFileSizeMb.value = maxFileSizeMb.value appMaxPageCount.value = maxPageCount.value @@ -147,10 +176,12 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { * guard does not need to know about the FeatureFlag union. */ function modeFlags(): { ask: boolean; inspect: boolean; chunks: boolean } { + // Mode key 'chunks' is kept until T3 (Linked view) renames the DocMode + // union and route segment. The flag is already linkedModeEnabled. return { ask: askModeEnabled.value, inspect: inspectModeEnabled.value, - chunks: chunksModeEnabled.value, + chunks: linkedModeEnabled.value, } } @@ -161,8 +192,10 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { maxFileSizeMb, ingestionAvailable, reasoningAvailable, + studioModeEnabled, + ragPipelineEnabled, inspectModeEnabled, - chunksModeEnabled, + linkedModeEnabled, askModeEnabled, appVersion, loaded, diff --git a/frontend/src/features/reasoning/ui/ReasoningPanel.vue b/frontend/src/features/reasoning/ui/ReasoningPanel.vue index 64f4e5f..e502549 100644 --- a/frontend/src/features/reasoning/ui/ReasoningPanel.vue +++ b/frontend/src/features/reasoning/ui/ReasoningPanel.vue @@ -108,7 +108,7 @@ const props = defineProps<{ /** * The live Cytoscape instance from the GraphView. May be `null` while the * graph is loading or if Maintain hasn't been run for this document. - * Passed down from StudioPage via `graphViewRef.cy`. + * Passed down by the host page via `graphViewRef.cy`. */ cy: Core | null }>() diff --git a/frontend/src/shared/i18n.test.ts b/frontend/src/shared/i18n.test.ts index a96ce62..ac33012 100644 --- a/frontend/src/shared/i18n.test.ts +++ b/frontend/src/shared/i18n.test.ts @@ -50,7 +50,9 @@ describe('useI18n', () => { const { t } = useI18n() expect(t('history.tabAnalyses')).toBe('Analyses') expect(t('history.tabDocuments')).toBe('Documents') - expect(t('history.emptyDocs')).toBe('Aucun document. Importez un document depuis le Studio.') + expect(t('history.emptyDocs')).toBe( + 'Aucun document. Importez un document depuis la bibliothèque.', + ) }) it('has history tab keys in English', () => { @@ -59,7 +61,7 @@ describe('useI18n', () => { const { t } = useI18n() expect(t('history.tabAnalyses')).toBe('Analyses') expect(t('history.tabDocuments')).toBe('Documents') - expect(t('history.emptyDocs')).toBe('No documents yet. Upload a document from the Studio.') + expect(t('history.emptyDocs')).toBe('No documents yet. Upload a document from the library.') }) it('has detailed pipeline option hints in French', () => { diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index 2654e78..1566e54 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -207,8 +207,8 @@ const messages: Messages = { 'history.title': 'Historique', 'history.tabAnalyses': 'Analyses', 'history.tabDocuments': 'Documents', - 'history.empty': 'Aucune analyse. Allez dans Studio pour analyser votre premier document.', - 'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.', + 'history.empty': 'Aucune analyse. Analysez votre premier document pour commencer.', + 'history.emptyDocs': 'Aucun document. Importez un document depuis la bibliothèque.', 'history.open': 'Ouvrir', // Chunking @@ -264,7 +264,7 @@ const messages: Messages = { 'reasoning.uploading': 'Import du document...', 'reasoning.existingDocs': 'Documents déjà analysés', 'reasoning.noAnalyzedDocs': - 'Aucun des documents existants n\u2019a encore été analysé — lance-en un depuis Studio, ou dépose un nouveau PDF ci-dessus.', + 'Aucun des documents existants n\u2019a encore été analysé — lance-en un depuis la bibliothèque, ou dépose un nouveau PDF ci-dessus.', 'reasoning.pagesCount': '{n} pages', 'reasoning.changeDoc': 'Changer de document', 'reasoning.modeSwitchLabel': 'Mode d\u2019affichage', @@ -712,8 +712,8 @@ const messages: Messages = { 'history.title': 'History', 'history.tabAnalyses': 'Analyses', 'history.tabDocuments': 'Documents', - 'history.empty': 'No analyses yet. Go to Studio to analyze your first document.', - 'history.emptyDocs': 'No documents yet. Upload a document from the Studio.', + 'history.empty': 'No analyses yet. Analyze your first document to get started.', + 'history.emptyDocs': 'No documents yet. Upload a document from the library.', 'history.open': 'Open', 'studio.prepare': 'Prepare', @@ -767,7 +767,7 @@ const messages: Messages = { 'reasoning.uploading': 'Uploading document...', 'reasoning.existingDocs': 'Previously analyzed documents', 'reasoning.noAnalyzedDocs': - 'None of your existing documents have been analyzed yet — run one from Studio, or drop a new PDF above.', + 'None of your existing documents have been analyzed yet — run one from the library, or drop a new PDF above.', 'reasoning.pagesCount': '{n} pages', 'reasoning.changeDoc': 'Change document', 'reasoning.modeSwitchLabel': 'View mode', diff --git a/frontend/src/shared/routing/resolveSurface.test.ts b/frontend/src/shared/routing/resolveSurface.test.ts new file mode 100644 index 0000000..98b4fe4 --- /dev/null +++ b/frontend/src/shared/routing/resolveSurface.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +import { ROUTES } from './names' +import { resolveSurface } from './resolveSurface' + +describe('resolveSurface', () => { + it('allows studio routes when studio surface is enabled', () => { + expect(resolveSurface(ROUTES.STUDIO, { studio: true, rag: false })).toBeNull() + expect(resolveSurface(ROUTES.HISTORY, { studio: true, rag: true })).toBeNull() + }) + + it('allows rag routes when rag surface is enabled', () => { + expect(resolveSurface(ROUTES.DOCS_LIBRARY, { studio: false, rag: true })).toBeNull() + expect(resolveSurface(ROUTES.DOC_WORKSPACE, { studio: true, rag: true })).toBeNull() + }) + + it('redirects studio routes to docs library when studio off and rag on', () => { + expect(resolveSurface(ROUTES.STUDIO, { studio: false, rag: true })).toBe(ROUTES.DOCS_LIBRARY) + expect(resolveSurface(ROUTES.HISTORY, { studio: false, rag: true })).toBe(ROUTES.DOCS_LIBRARY) + expect(resolveSurface(ROUTES.DOCUMENTS, { studio: false, rag: true })).toBe(ROUTES.DOCS_LIBRARY) + expect(resolveSurface(ROUTES.SEARCH, { studio: false, rag: true })).toBe(ROUTES.DOCS_LIBRARY) + }) + + it('redirects rag routes to studio when rag off and studio on', () => { + expect(resolveSurface(ROUTES.DOCS_LIBRARY, { studio: true, rag: false })).toBe(ROUTES.STUDIO) + expect(resolveSurface(ROUTES.STORES_LIST, { studio: true, rag: false })).toBe(ROUTES.STUDIO) + expect(resolveSurface(ROUTES.RUNS, { studio: true, rag: false })).toBe(ROUTES.STUDIO) + }) + + it('falls back to home when both surfaces are off', () => { + // Defensive case — backend refuses to start in this state. + expect(resolveSurface(ROUTES.STUDIO, { studio: false, rag: false })).toBe(ROUTES.HOME) + expect(resolveSurface(ROUTES.DOCS_LIBRARY, { studio: false, rag: false })).toBe(ROUTES.HOME) + }) + + it('leaves surface-neutral routes untouched', () => { + expect(resolveSurface(ROUTES.HOME, { studio: false, rag: true })).toBeNull() + expect(resolveSurface(ROUTES.SETTINGS, { studio: false, rag: true })).toBeNull() + expect(resolveSurface(ROUTES.REASONING, { studio: false, rag: true })).toBeNull() + }) +}) diff --git a/frontend/src/shared/routing/resolveSurface.ts b/frontend/src/shared/routing/resolveSurface.ts new file mode 100644 index 0000000..efec7f8 --- /dev/null +++ b/frontend/src/shared/routing/resolveSurface.ts @@ -0,0 +1,48 @@ +import { ROUTES } from './names' + +/** + * Surface gating (#257). + * + * Two master flags select which UI surface is exposed: + * - `studioMode` gates the legacy Studio surface + * - `ragPipeline` gates the new doc-centric RAG pipeline + * + * `resolveSurface` is a pure helper consumed by the router's `beforeEach` + * guard. Returns the target route name to redirect to, or `null` when + * the requested route is allowed. + */ + +export const STUDIO_SURFACE_ROUTES: ReadonlySet = new Set([ + ROUTES.STUDIO, + ROUTES.HISTORY, + ROUTES.DOCUMENTS, + ROUTES.SEARCH, +]) + +export const RAG_SURFACE_ROUTES: ReadonlySet = new Set([ + ROUTES.DOCS_LIBRARY, + ROUTES.DOCS_NEW, + ROUTES.DOC_WORKSPACE, + ROUTES.STORES_LIST, + ROUTES.STORE_CREATE, + ROUTES.STORE_DETAIL, + ROUTES.STORE_EDIT, + ROUTES.STORE_QUERY, + ROUTES.RUNS, + ROUTES.RUN_DETAIL, +]) + +export interface SurfaceFlags { + studio: boolean + rag: boolean +} + +export function resolveSurface(routeName: string, flags: SurfaceFlags): string | null { + if (STUDIO_SURFACE_ROUTES.has(routeName) && !flags.studio) { + return flags.rag ? ROUTES.DOCS_LIBRARY : ROUTES.HOME + } + if (RAG_SURFACE_ROUTES.has(routeName) && !flags.rag) { + return flags.studio ? ROUTES.STUDIO : ROUTES.HOME + } + return null +}