From 72669f5fa8ba0edc00b10aa758a3739e68189638 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Mon, 11 May 2026 16:30:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(#263):=20DocWorkspacePage=20shell=20?= =?UTF-8?q?=E2=80=94=20Linked/Inspect/Compare=20switcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous 3-tab strip (Ask / Inspect / Chunks) with a top-right view switcher per the 0.6.1 mockup. The switcher renders three buttons; Compare is intentionally disabled until 0.9.0 ships the diff view. DocMode union shrinks to { 'inspect', 'linked' } — only the modes that render a tab today. The legacy 'chunks' mode value is renamed to 'linked' to anticipate the upcoming Linked view refactor (T3); ?mode=chunks remains accepted as a backward-compat alias in parseMode so existing bookmarks keep working. 'ask' is dropped from the workspace per ADR #259 (Ask moves out of the doc workspace and is handled separately via the standalone /reasoning page). The askMode feature flag is preserved in the registry so the existing reasoning surface stays gateable; it is no longer part of modeFlags() which now returns { inspect, linked }. DEFAULT_MODE shifts from 'ask' to 'linked' (the mockup's default view). MODE_PRIORITY drops to ['linked', 'inspect']. DocWorkspaceHeader gains an `actions` named slot, used by the workspace page to inject the switcher inside the sticky header, matching the mockup layout. i18n cleanup: - adds workspace.tabs.linked, workspace.tabs.compare, workspace.compareSoon - removes workspace.tabs.ask, workspace.tabs.chunks, and the obsolete workspace.{askComingSoon,inspectComingSoon,*Hint} placeholders - renames breadcrumb.mode.chunks -> breadcrumb.mode.linked and drops breadcrumb.mode.ask DocAskTab.vue is kept on disk (no longer imported by the workspace) to avoid touching reasoning-related code in this refactor — its removal, if any, is the call of the separate Ask-handling track. Tests: - modes.test.ts asserts the new isDocMode/parseMode behaviour including the chunks->linked alias - resolveMode.test.ts targets the 2-mode priority - router.test.ts covers both ?mode=inspect and the chunks->linked alias - feature-flags/store.test.ts asserts modeFlags() returns { inspect, linked } --- frontend/src/app/router/router.test.ts | 22 +++- .../document/ui/DocWorkspaceHeader.vue | 11 ++ .../src/features/feature-flags/store.test.ts | 5 +- frontend/src/features/feature-flags/store.ts | 7 +- frontend/src/pages/DocWorkspacePage.vue | 119 +++++++++++------- frontend/src/shared/i18n.ts | 29 ++--- frontend/src/shared/routing/modes.test.ts | 19 ++- frontend/src/shared/routing/modes.ts | 25 ++-- .../src/shared/routing/resolveMode.test.ts | 24 ++-- frontend/src/shared/routing/resolveMode.ts | 6 +- 10 files changed, 164 insertions(+), 103 deletions(-) diff --git a/frontend/src/app/router/router.test.ts b/frontend/src/app/router/router.test.ts index b43713b..12d88ae 100644 --- a/frontend/src/app/router/router.test.ts +++ b/frontend/src/app/router/router.test.ts @@ -49,17 +49,17 @@ describe('router', () => { const route = router.resolve({ name: ROUTES.DOC_WORKSPACE, params: { id: 'abc' }, - query: { mode: 'chunks' }, + query: { mode: 'inspect' }, }) const propsFn = route.matched[0]?.props as | { default?: (r: typeof route) => unknown } | undefined const computed = (propsFn?.default ?? (() => null))(route) as { id: string; mode: string } expect(computed.id).toBe('abc') - expect(computed.mode).toBe('chunks') + expect(computed.mode).toBe('inspect') }) - it('falls back to ask when mode is unknown', () => { + it('falls back to linked when mode is unknown', () => { const router = buildRouter() const route = router.resolve({ name: ROUTES.DOC_WORKSPACE, @@ -70,7 +70,21 @@ describe('router', () => { | { default?: (r: typeof route) => unknown } | undefined const computed = (propsFn?.default ?? (() => null))(route) as { mode: string } - expect(computed.mode).toBe('ask') + expect(computed.mode).toBe('linked') + }) + + it('maps the legacy ?mode=chunks alias to linked', () => { + const router = buildRouter() + const route = router.resolve({ + name: ROUTES.DOC_WORKSPACE, + params: { id: 'abc' }, + query: { mode: 'chunks' }, + }) + const propsFn = route.matched[0]?.props as + | { default?: (r: typeof route) => unknown } + | undefined + const computed = (propsFn?.default ?? (() => null))(route) as { mode: string } + expect(computed.mode).toBe('linked') }) it('redirects unknown paths to /', () => { diff --git a/frontend/src/features/document/ui/DocWorkspaceHeader.vue b/frontend/src/features/document/ui/DocWorkspaceHeader.vue index 93a802f..68e4261 100644 --- a/frontend/src/features/document/ui/DocWorkspaceHeader.vue +++ b/frontend/src/features/document/ui/DocWorkspaceHeader.vue @@ -10,6 +10,9 @@

{{ doc.filename }}

+
+ +
@@ -79,6 +82,14 @@ defineProps<{ min-width: 0; } +.workspace-header-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + flex-shrink: 0; +} + .workspace-header-meta { display: flex; align-items: center; diff --git a/frontend/src/features/feature-flags/store.test.ts b/frontend/src/features/feature-flags/store.test.ts index f2bee9f..3c38816 100644 --- a/frontend/src/features/feature-flags/store.test.ts +++ b/frontend/src/features/feature-flags/store.test.ts @@ -220,16 +220,15 @@ describe('useFeatureFlagStore', () => { expect(store.isEnabled('askMode')).toBe(true) }) - it('modeFlags() returns the three flags in a Record', async () => { + it('modeFlags() returns the two workspace flags in a Record', async () => { mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local', inspectModeEnabled: true, linkedModeEnabled: false, - askModeEnabled: true, }) const store = useFeatureFlagStore() await store.load() - expect(store.modeFlags()).toEqual({ ask: true, chunks: false, inspect: true }) + expect(store.modeFlags()).toEqual({ inspect: true, linked: false }) }) }) diff --git a/frontend/src/features/feature-flags/store.ts b/frontend/src/features/feature-flags/store.ts index fa83c05..0cafe49 100644 --- a/frontend/src/features/feature-flags/store.ts +++ b/frontend/src/features/feature-flags/store.ts @@ -175,13 +175,10 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { * workspace mode flags as a `Record` so the routing * 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. + function modeFlags(): { inspect: boolean; linked: boolean } { return { - ask: askModeEnabled.value, inspect: inspectModeEnabled.value, - chunks: linkedModeEnabled.value, + linked: linkedModeEnabled.value, } } diff --git a/frontend/src/pages/DocWorkspacePage.vue b/frontend/src/pages/DocWorkspacePage.vue index e6feb07..203cf6e 100644 --- a/frontend/src/pages/DocWorkspacePage.vue +++ b/frontend/src/pages/DocWorkspacePage.vue @@ -15,40 +15,45 @@ @@ -61,7 +66,7 @@ import { RouterLink, useRouter, useRoute } from 'vue-router' import type { Document } from '../shared/types' import { fetchDocument } from '../features/document/api' import { useFeatureFlagStore } from '../features/feature-flags/store' -import { ALL_MODES, type DocMode } from '../shared/routing/modes' +import { type DocMode } from '../shared/routing/modes' import { resolveMode } from '../shared/routing/resolveMode' import { useCrumbs } from '../shared/breadcrumb/store' import { truncate } from '../shared/breadcrumb/text' @@ -71,7 +76,6 @@ import { ROUTES } from '../shared/routing/names' import DocWorkspaceHeader from '../features/document/ui/DocWorkspaceHeader.vue' import DocChunksTab from './DocChunksTab.vue' import DocInspectTab from './DocInspectTab.vue' -import DocAskTab from './DocAskTab.vue' const props = defineProps<{ id: string; mode: DocMode }>() @@ -86,6 +90,18 @@ const docError = ref(null) const activeMode = ref(props.mode) +// Switcher entries. Compare is intentionally disabled — the view ships +// in 0.9.0; the button is kept visible so users can see the roadmap. +interface ViewEntry { + key: DocMode | 'compare' + disabled: boolean +} +const VIEWS: readonly ViewEntry[] = [ + { key: 'linked', disabled: false }, + { key: 'inspect', disabled: false }, + { key: 'compare', disabled: true }, +] + const crumbs = computed(() => [ { kind: 'link', label: t('breadcrumb.studio'), to: { name: ROUTES.HOME } }, { @@ -97,12 +113,24 @@ const crumbs = computed(() => [ ]) useCrumbs(crumbs) -function modeEnabled(m: DocMode): boolean { - return flagStore.modeFlags()[m] +function isModeEnabled(key: DocMode | 'compare'): boolean { + if (key === 'compare') return false + return flagStore.modeFlags()[key] +} + +function viewTooltip(view: ViewEntry): string | undefined { + if (view.disabled) return t('workspace.compareSoon') + if (!isModeEnabled(view.key)) return t('workspace.modeDisabled') + return undefined +} + +function onViewClick(view: ViewEntry): void { + if (view.disabled || view.key === 'compare') return + switchMode(view.key) } function switchMode(m: DocMode): void { - if (!modeEnabled(m)) return + if (!isModeEnabled(m)) return activeMode.value = m router.replace({ query: { ...route.query, mode: m } }) } @@ -192,39 +220,42 @@ watch( color: var(--text); } -.tab-strip { - display: flex; - gap: 0; - border-bottom: 1px solid var(--border); +.view-switcher { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; background: var(--bg-surface); - padding: 0 20px; - flex-shrink: 0; } -.tab-btn { - padding: 8px 16px; +.view-btn { + padding: 6px 12px; font-size: 13px; font-weight: 500; color: var(--text-muted); background: none; border: none; - border-bottom: 2px solid transparent; + border-right: 1px solid var(--border); cursor: pointer; transition: all var(--transition); - margin-bottom: -1px; } -.tab-btn:hover:not(.disabled) { +.view-btn:last-child { + border-right: none; +} + +.view-btn:hover:not(.disabled) { color: var(--text); + background: var(--bg-hover); } -.tab-btn.active { +.view-btn.active { color: var(--accent); - border-bottom-color: var(--accent); + background: var(--bg-active); } -.tab-btn.disabled { - opacity: 0.35; +.view-btn.disabled { + opacity: 0.4; cursor: not-allowed; } diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index 1566e54..1c7e205 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -29,9 +29,8 @@ const messages: Messages = { // Breadcrumb (0.6.0 doc workspace — #208) 'breadcrumb.aria': "Fil d'Ariane", 'breadcrumb.studio': 'Studio', - 'breadcrumb.mode.ask': 'Ask', + 'breadcrumb.mode.linked': 'Linked', 'breadcrumb.mode.inspect': 'Inspect', - 'breadcrumb.mode.chunks': 'Chunks', // Feature flags (0.6.0 — #210) 'flags.allModesDisabled': @@ -385,17 +384,13 @@ const messages: Messages = { 'ask.noAnalysis': 'Aucune analyse disponible', 'ask.noAnalysisSub': 'Analysez ce document dans le Studio avant de poser une question.', - // Doc workspace (#216, #218) - 'workspace.tabs.ask': 'Ask', + // Doc workspace (#216, #218 \u2014 switcher refactored in #263) + 'workspace.tabs.linked': 'Linked', 'workspace.tabs.inspect': 'Inspect', - 'workspace.tabs.chunks': 'Chunks', + 'workspace.tabs.compare': 'Compare', 'workspace.backToLibrary': 'Retour \u00e0 la biblioth\u00e8que', 'workspace.modeDisabled': 'Mode d\u00e9sactiv\u00e9 pour ce d\u00e9ploiement', - 'workspace.inspectComingSoon': 'Inspect \u2014 disponible en 0.7.0', - 'workspace.inspectComingSoonHint': 'La vue arborescence + bbox arrive prochainement.', - 'workspace.askComingSoon': 'Ask \u2014 disponible en 0.7.0', - 'workspace.askComingSoonHint': - 'Le raisonnement agentique sur le document arrive prochainement.', + 'workspace.compareSoon': 'Compare \u2014 disponible en 0.9.0', // Doc tree rail (#217) 'tree.empty': "Aucun n\u0153ud dans l'arbre.", @@ -541,9 +536,8 @@ const messages: Messages = { // Breadcrumb (0.6.0 doc workspace — #208) 'breadcrumb.aria': 'Breadcrumb', 'breadcrumb.studio': 'Studio', - 'breadcrumb.mode.ask': 'Ask', + 'breadcrumb.mode.linked': 'Linked', 'breadcrumb.mode.inspect': 'Inspect', - 'breadcrumb.mode.chunks': 'Chunks', // Feature flags (0.6.0 — #210) 'flags.allModesDisabled': @@ -883,16 +877,13 @@ const messages: Messages = { 'ask.noAnalysis': 'No analysis available', 'ask.noAnalysisSub': 'Analyze this document in Studio before asking a question.', - // Doc workspace (#216, #218) - 'workspace.tabs.ask': 'Ask', + // Doc workspace (#216, #218 — switcher refactored in #263) + 'workspace.tabs.linked': 'Linked', 'workspace.tabs.inspect': 'Inspect', - 'workspace.tabs.chunks': 'Chunks', + 'workspace.tabs.compare': 'Compare', 'workspace.backToLibrary': 'Back to library', 'workspace.modeDisabled': 'Mode disabled for this deployment', - 'workspace.inspectComingSoon': 'Inspect \u2014 coming in 0.7.0', - 'workspace.inspectComingSoonHint': 'Tree + bbox view coming soon.', - 'workspace.askComingSoon': 'Ask \u2014 coming in 0.7.0', - 'workspace.askComingSoonHint': 'Agentic reasoning over the document coming soon.', + 'workspace.compareSoon': 'Compare — coming in 0.9.0', // Doc tree rail (#217) 'tree.empty': 'No nodes in tree.', diff --git a/frontend/src/shared/routing/modes.test.ts b/frontend/src/shared/routing/modes.test.ts index 7de79fa..303272a 100644 --- a/frontend/src/shared/routing/modes.test.ts +++ b/frontend/src/shared/routing/modes.test.ts @@ -3,11 +3,11 @@ import { describe, expect, it } from 'vitest' import { ALL_MODES, DEFAULT_MODE, isDocMode, parseMode } from './modes' describe('isDocMode', () => { - it.each(['ask', 'inspect', 'chunks'])('accepts %s', (value) => { + it.each(['inspect', 'linked'])('accepts %s', (value) => { expect(isDocMode(value)).toBe(true) }) - it.each([undefined, null, '', 'foo', 42, {}, []])('rejects %s', (value) => { + it.each([undefined, null, '', 'foo', 'ask', 'chunks', 42, {}, []])('rejects %s', (value) => { expect(isDocMode(value)).toBe(false) }) }) @@ -17,19 +17,26 @@ describe('parseMode', () => { expect(parseMode(undefined)).toBe(DEFAULT_MODE) expect(parseMode(null)).toBe(DEFAULT_MODE) expect(parseMode('garbage')).toBe(DEFAULT_MODE) - expect(parseMode(['chunks'])).toBe(DEFAULT_MODE) // arrays not accepted + expect(parseMode(['linked'])).toBe(DEFAULT_MODE) // arrays not accepted }) - it.each(['ask', 'inspect', 'chunks'] as const)('respects %s', (mode) => { + it.each(['linked', 'inspect'] as const)('respects %s', (mode) => { expect(parseMode(mode)).toBe(mode) }) + + it('maps the legacy ?mode=chunks alias to linked', () => { + expect(parseMode('chunks')).toBe('linked') + }) }) describe('ALL_MODES', () => { it('lists every mode exactly once', () => { expect(new Set(ALL_MODES).size).toBe(ALL_MODES.length) - expect(ALL_MODES).toContain('ask') + expect(ALL_MODES).toContain('linked') expect(ALL_MODES).toContain('inspect') - expect(ALL_MODES).toContain('chunks') + }) + + it('puts linked first (default view)', () => { + expect(ALL_MODES[0]).toBe('linked') }) }) diff --git a/frontend/src/shared/routing/modes.ts b/frontend/src/shared/routing/modes.ts index 3cccc8e..a7ef44c 100644 --- a/frontend/src/shared/routing/modes.ts +++ b/frontend/src/shared/routing/modes.ts @@ -1,24 +1,35 @@ /** * Doc workspace mode parsing. * - * The doc workspace at `/docs/:id` exposes three modes via the `?mode=` + * The doc workspace at `/docs/:id` exposes its content via the `?mode=` * query param. Anything missing or unknown resolves to the default, - * `ask`, so a malformed URL never produces a broken page. + * `linked`, so a malformed URL never produces a broken page. * * #210 layers feature-flag-aware redirection on top: if the requested * mode is disabled for the current tenant, the router replaces it with - * the first enabled mode (priority `ask` > `chunks` > `inspect`). + * the first enabled mode (priority `linked` > `inspect`). + * + * #263 renames the legacy `chunks` mode to `linked` and drops `ask` from + * the workspace (Ask is handled separately via the standalone + * `/reasoning` page). The Compare view (#263) is rendered as a disabled + * button in the switcher — no mode value, no route segment. + * + * Backward compatibility: `?mode=chunks` is accepted and silently mapped + * to `linked` so existing bookmarks keep working. */ -export type DocMode = 'ask' | 'inspect' | 'chunks' +export type DocMode = 'inspect' | 'linked' -export const DEFAULT_MODE: DocMode = 'ask' -export const ALL_MODES: readonly DocMode[] = ['ask', 'inspect', 'chunks'] as const +export const DEFAULT_MODE: DocMode = 'linked' +export const ALL_MODES: readonly DocMode[] = ['linked', 'inspect'] as const + +const LEGACY_CHUNKS_ALIAS = 'chunks' export function isDocMode(value: unknown): value is DocMode { - return value === 'ask' || value === 'inspect' || value === 'chunks' + return value === 'inspect' || value === 'linked' } export function parseMode(raw: unknown): DocMode { + if (raw === LEGACY_CHUNKS_ALIAS) return 'linked' return isDocMode(raw) ? raw : DEFAULT_MODE } diff --git a/frontend/src/shared/routing/resolveMode.test.ts b/frontend/src/shared/routing/resolveMode.test.ts index 834cfc1..0b47b7f 100644 --- a/frontend/src/shared/routing/resolveMode.test.ts +++ b/frontend/src/shared/routing/resolveMode.test.ts @@ -3,37 +3,35 @@ import { describe, expect, it } from 'vitest' import type { DocMode } from './modes' import { MODE_PRIORITY, resolveMode } from './resolveMode' -const allEnabled: Record = { ask: true, chunks: true, inspect: true } -const allDisabled: Record = { ask: false, chunks: false, inspect: false } +const allEnabled: Record = { linked: true, inspect: true } +const allDisabled: Record = { linked: 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('linked', allEnabled)).toBe('linked') 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') + expect(resolveMode('linked', { linked: false, inspect: true })).toBe('inspect') + expect(resolveMode('inspect', { linked: true, inspect: false })).toBe('linked') }) - 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('honours the priority order linked > inspect', () => { + expect(resolveMode(undefined, allEnabled)).toBe('linked') + expect(resolveMode(undefined, { linked: false, inspect: true })).toBe('inspect') }) it('returns null when no mode is enabled', () => { - expect(resolveMode('ask', allDisabled)).toBeNull() + expect(resolveMode('linked', allDisabled)).toBeNull() expect(resolveMode(undefined, allDisabled)).toBeNull() }) it('handles missing requested gracefully', () => { - expect(resolveMode(undefined, allEnabled)).toBe('ask') + expect(resolveMode(undefined, allEnabled)).toBe('linked') }) it('exposes the priority in the right order', () => { - expect(MODE_PRIORITY).toEqual(['ask', 'chunks', 'inspect']) + expect(MODE_PRIORITY).toEqual(['linked', 'inspect']) }) }) diff --git a/frontend/src/shared/routing/resolveMode.ts b/frontend/src/shared/routing/resolveMode.ts index 133ab33..b95654a 100644 --- a/frontend/src/shared/routing/resolveMode.ts +++ b/frontend/src/shared/routing/resolveMode.ts @@ -1,7 +1,7 @@ import { type DocMode } from './modes' /** - * Doc workspace mode resolution under feature flags (#210). + * Doc workspace mode resolution under feature flags (#210, updated #263). * * The router consults this when a user opens `/docs/:id?mode=`: * @@ -9,8 +9,10 @@ import { type DocMode } from './modes' * - 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). + * + * Priority: `linked` first (the mockup's default view), then `inspect`. */ -export const MODE_PRIORITY: readonly DocMode[] = ['ask', 'chunks', 'inspect'] as const +export const MODE_PRIORITY: readonly DocMode[] = ['linked', 'inspect'] as const export function resolveMode( requested: DocMode | undefined,