feat(#263): DocWorkspacePage shell — Linked/Inspect/Compare switcher

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 }
This commit is contained in:
Pier-Jean Malandrino 2026-05-11 16:30:46 +02:00
parent 27e3323bff
commit 72669f5fa8
10 changed files with 164 additions and 103 deletions

View file

@ -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 /', () => {

View file

@ -10,6 +10,9 @@
</svg>
<h1 class="workspace-title" :title="doc.filename">{{ doc.filename }}</h1>
<StatusBadge :state="doc.lifecycleState" />
<div class="workspace-header-actions">
<slot name="actions" />
</div>
</div>
<div class="workspace-header-meta">
<div v-if="doc.stores?.length" class="workspace-stores">
@ -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;

View file

@ -220,16 +220,15 @@ describe('useFeatureFlagStore', () => {
expect(store.isEnabled('askMode')).toBe(true)
})
it('modeFlags() returns the three flags in a Record<DocMode, boolean>', async () => {
it('modeFlags() returns the two workspace flags in a Record<DocMode, boolean>', 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 })
})
})

View file

@ -175,13 +175,10 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
* 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 } {
// 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,
}
}

View file

@ -15,40 +15,45 @@
<template v-else-if="doc">
<!-- Sticky header (#218) -->
<DocWorkspaceHeader :doc="doc" />
<!-- Tab strip (#216) -->
<div class="tab-strip" role="tablist" data-e2e="tab-strip">
<DocWorkspaceHeader :doc="doc">
<template #actions>
<!-- View switcher (#263) Linked / Inspect / Compare. Compare is
rendered as a disabled placeholder until #270 (0.9.0). -->
<div class="view-switcher" role="tablist" data-e2e="view-switcher">
<button
v-for="m in ALL_MODES"
:key="m"
class="tab-btn"
:class="{ active: activeMode === m, disabled: !modeEnabled(m) }"
v-for="view in VIEWS"
:key="view.key"
class="view-btn"
:class="{
active: !view.disabled && activeMode === view.key,
disabled: view.disabled || !isModeEnabled(view.key),
}"
role="tab"
:aria-selected="activeMode === m"
:disabled="!modeEnabled(m)"
:title="!modeEnabled(m) ? t('workspace.modeDisabled') : undefined"
:data-e2e="`tab-${m}`"
@click="switchMode(m)"
:aria-selected="!view.disabled && activeMode === view.key"
:disabled="view.disabled || !isModeEnabled(view.key)"
:title="viewTooltip(view)"
:data-e2e="`view-${view.key}`"
@click="onViewClick(view)"
>
{{ t(`workspace.tabs.${m}`) }}
{{ t(`workspace.tabs.${view.key}`) }}
</button>
</div>
</template>
</DocWorkspaceHeader>
<!-- Tab content lazy loaded (#216) -->
<!-- :key on docId forces a clean remount when navigating to a different doc,
preventing stale state (bbox, selectedPage, etc.) from leaking. -->
<!-- View content lazy loaded (#216). :key on docId forces a clean
remount when navigating to a different doc, preventing stale state
(bbox, selectedPage, etc.) from leaking. -->
<div class="tab-content" role="tabpanel" data-e2e="tab-content">
<Suspense>
<DocChunksTab
v-if="activeMode === 'chunks'"
v-if="activeMode === 'linked'"
:key="id"
:doc-id="id"
:available-stores="doc.stores ?? []"
:store-links="doc.storeLinks"
/>
<DocInspectTab v-else-if="activeMode === 'inspect'" :key="id" :doc-id="id" />
<DocAskTab v-else-if="activeMode === 'ask'" :key="id" :doc-id="id" />
</Suspense>
</div>
</template>
@ -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<string | null>(null)
const activeMode = ref<DocMode>(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<Crumb[]>(() => [
{ kind: 'link', label: t('breadcrumb.studio'), to: { name: ROUTES.HOME } },
{
@ -97,12 +113,24 @@ const crumbs = computed<Crumb[]>(() => [
])
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;
}

View file

@ -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.',

View file

@ -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')
})
})

View file

@ -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
}

View file

@ -3,37 +3,35 @@ 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 }
const allEnabled: Record<DocMode, boolean> = { linked: true, inspect: true }
const allDisabled: Record<DocMode, boolean> = { 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'])
})
})

View file

@ -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=<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,