diff --git a/frontend/src/app/App.vue b/frontend/src/app/App.vue
index db3837e..b090a42 100644
--- a/frontend/src/app/App.vue
+++ b/frontend/src/app/App.vue
@@ -40,6 +40,7 @@
@@ -50,6 +51,8 @@
import { ref, computed } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { AppSidebar } from '../shared/ui/index'
+import AppBreadcrumb from '../shared/breadcrumb/AppBreadcrumb.vue'
+import { useBreadcrumbStore } from '../shared/breadcrumb/store'
import { useSettingsStore } from '../features/settings/store'
import { useDocumentStore } from '../features/document/store'
import { useFeatureFlag } from '../features/feature-flags'
@@ -58,6 +61,7 @@ import { useI18n } from '../shared/i18n'
useSettingsStore()
const flagStore = useFeatureFlagStore()
+const breadcrumbStore = useBreadcrumbStore()
const { t } = useI18n()
const router = useRouter()
const documentStore = useDocumentStore()
diff --git a/frontend/src/pages/DocWorkspacePage.vue b/frontend/src/pages/DocWorkspacePage.vue
index 37ea58f..d783852 100644
--- a/frontend/src/pages/DocWorkspacePage.vue
+++ b/frontend/src/pages/DocWorkspacePage.vue
@@ -8,9 +8,13 @@
diff --git a/frontend/src/shared/breadcrumb/AppBreadcrumb.vue b/frontend/src/shared/breadcrumb/AppBreadcrumb.vue
new file mode 100644
index 0000000..e424b55
--- /dev/null
+++ b/frontend/src/shared/breadcrumb/AppBreadcrumb.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/shared/breadcrumb/store.test.ts b/frontend/src/shared/breadcrumb/store.test.ts
new file mode 100644
index 0000000..b199873
--- /dev/null
+++ b/frontend/src/shared/breadcrumb/store.test.ts
@@ -0,0 +1,71 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { ref, type Ref } from 'vue'
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { useBreadcrumbStore, useCrumbs } from './store'
+import type { Crumb } from './types'
+
+/**
+ * The composable side of `useCrumbs` registers `onBeforeUnmount` which
+ * requires a component lifecycle context. The project does not have
+ * `@vue/test-utils`, so we test the store API directly. The composable
+ * lifecycle is exercised end-to-end by the integration tests landing
+ * in #211 / #216.
+ *
+ * We DO test the reactive-input behaviour by calling `useCrumbs` with
+ * a ref inside a setup-style scope; the watch fires synchronously
+ * thanks to `immediate: true`.
+ */
+describe('useBreadcrumbStore', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ })
+
+ it('starts empty', () => {
+ const store = useBreadcrumbStore()
+ expect(store.crumbs).toEqual([])
+ })
+
+ it('setCrumbs replaces the segments', () => {
+ const store = useBreadcrumbStore()
+ const next: Crumb[] = [{ kind: 'leaf', label: 'a' }]
+ store.setCrumbs(next)
+ expect(store.crumbs).toEqual(next)
+ })
+
+ it('clear empties the segments', () => {
+ const store = useBreadcrumbStore()
+ store.setCrumbs([{ kind: 'leaf', label: 'a' }])
+ store.clear()
+ expect(store.crumbs).toEqual([])
+ })
+})
+
+describe('useCrumbs (reactive source path)', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ })
+
+ it('seeds the store from a ref synchronously', () => {
+ const store = useBreadcrumbStore()
+ const dynamic: Ref = ref([{ kind: 'leaf', label: 'first' }])
+ // Note: `useCrumbs` calls `onBeforeUnmount` which is a no-op when
+ // there is no current component — Vue logs a warning but the watch
+ // path still installs.
+ useCrumbs(dynamic)
+ expect(store.crumbs).toEqual([{ kind: 'leaf', label: 'first' }])
+
+ dynamic.value = [{ kind: 'leaf', label: 'second' }]
+ // The watch is `immediate: true`; for subsequent updates it runs
+ // on the next microtask.
+ return Promise.resolve().then(() => {
+ expect(store.crumbs).toEqual([{ kind: 'leaf', label: 'second' }])
+ })
+ })
+
+ it('seeds the store from a static array', () => {
+ const store = useBreadcrumbStore()
+ useCrumbs([{ kind: 'leaf', label: 'static' }])
+ expect(store.crumbs).toEqual([{ kind: 'leaf', label: 'static' }])
+ })
+})
diff --git a/frontend/src/shared/breadcrumb/store.ts b/frontend/src/shared/breadcrumb/store.ts
new file mode 100644
index 0000000..6cd7ace
--- /dev/null
+++ b/frontend/src/shared/breadcrumb/store.ts
@@ -0,0 +1,53 @@
+import { defineStore } from 'pinia'
+import { onBeforeUnmount, watch, type Ref, type ComputedRef } from 'vue'
+
+import type { Crumb } from './types'
+
+/**
+ * Store the current breadcrumb segments. Pages set them on mount via
+ * `useCrumbs(crumbs)` and clear them on unmount automatically.
+ *
+ * The shell (`App.vue`) reads `crumbs` and renders `` —
+ * empty array → component renders nothing.
+ */
+export const useBreadcrumbStore = defineStore('breadcrumb', {
+ state: () => ({
+ crumbs: [] as Crumb[],
+ }),
+ actions: {
+ setCrumbs(crumbs: Crumb[]) {
+ this.crumbs = crumbs
+ },
+ clear() {
+ this.crumbs = []
+ },
+ },
+})
+
+/**
+ * Composable for pages to declare their breadcrumb. Auto-clears on
+ * unmount so a stale breadcrumb never leaks to the next route.
+ *
+ * Accepts a static array OR a reactive ref / computed so pages with
+ * async data (e.g. doc workspace fetching the doc title) can rebuild
+ * crumbs as the data lands.
+ */
+export function useCrumbs(source: Crumb[] | Ref | ComputedRef) {
+ const store = useBreadcrumbStore()
+
+ if (Array.isArray(source)) {
+ store.setCrumbs(source)
+ } else {
+ watch(
+ source,
+ (next) => {
+ store.setCrumbs(next)
+ },
+ { immediate: true },
+ )
+ }
+
+ onBeforeUnmount(() => {
+ store.clear()
+ })
+}
diff --git a/frontend/src/shared/breadcrumb/text.test.ts b/frontend/src/shared/breadcrumb/text.test.ts
new file mode 100644
index 0000000..9639e57
--- /dev/null
+++ b/frontend/src/shared/breadcrumb/text.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+
+import { truncate } from './text'
+
+describe('truncate', () => {
+ it('returns the input unchanged when shorter than the limit', () => {
+ expect(truncate('short', 10)).toBe('short')
+ expect(truncate('exactly10!', 10)).toBe('exactly10!')
+ })
+
+ it('truncates and adds an ellipsis when longer than the limit', () => {
+ expect(truncate('this is a long title', 10)).toBe('this is a…')
+ })
+
+ it('trims trailing whitespace before the ellipsis', () => {
+ expect(truncate('foo bar baz qux', 8)).toBe('foo bar…')
+ })
+
+ it('returns the input untouched for non-positive limits', () => {
+ expect(truncate('hello', 0)).toBe('hello')
+ expect(truncate('hello', -5)).toBe('hello')
+ })
+
+ it('handles empty strings', () => {
+ expect(truncate('', 10)).toBe('')
+ })
+})
diff --git a/frontend/src/shared/breadcrumb/text.ts b/frontend/src/shared/breadcrumb/text.ts
new file mode 100644
index 0000000..b8f6fd6
--- /dev/null
+++ b/frontend/src/shared/breadcrumb/text.ts
@@ -0,0 +1,14 @@
+/**
+ * Truncate `text` to at most `max` characters, replacing the tail with
+ * an ellipsis when shortened. Returns the original string if it
+ * already fits.
+ *
+ * Used by the breadcrumb to keep the topbar tidy when document
+ * filenames are long. The full title stays available via the `title`
+ * attribute on the segment so the user can hover to read it whole.
+ */
+export function truncate(text: string, max: number): string {
+ if (max <= 0 || text.length <= max) return text
+ // Reserve one character for the ellipsis so the visible length is `max`.
+ return text.slice(0, Math.max(0, max - 1)).trimEnd() + '…'
+}
diff --git a/frontend/src/shared/breadcrumb/types.ts b/frontend/src/shared/breadcrumb/types.ts
new file mode 100644
index 0000000..7707c92
--- /dev/null
+++ b/frontend/src/shared/breadcrumb/types.ts
@@ -0,0 +1,13 @@
+import type { RouteLocationRaw } from 'vue-router'
+
+/**
+ * One segment in a breadcrumb.
+ *
+ * - `LinkCrumb` renders as a `` to `to`.
+ * - `LeafCrumb` renders as a non-clickable label with `aria-current="page"`.
+ * The leaf is always the last segment; the rendering component refuses
+ * to render a leaf in a non-final position.
+ */
+export type LinkCrumb = { kind: 'link'; label: string; to: RouteLocationRaw }
+export type LeafCrumb = { kind: 'leaf'; label: string }
+export type Crumb = LinkCrumb | LeafCrumb
diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts
index 3b74247..63db0fd 100644
--- a/frontend/src/shared/i18n.ts
+++ b/frontend/src/shared/i18n.ts
@@ -19,6 +19,13 @@ const messages: Messages = {
// Top bar
'topbar.newAnalysis': 'Nouvelle analyse',
+ // Breadcrumb (0.6.0 doc workspace — #208)
+ 'breadcrumb.aria': "Fil d'Ariane",
+ 'breadcrumb.studio': 'Studio',
+ 'breadcrumb.mode.ask': 'Ask',
+ 'breadcrumb.mode.inspect': 'Inspect',
+ 'breadcrumb.mode.chunks': 'Chunks',
+
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
'comingSoon.title': 'Bientôt disponible',
'comingSoon.subtitle.docsLibrary':
@@ -307,6 +314,13 @@ const messages: Messages = {
'topbar.newAnalysis': 'New analysis',
+ // Breadcrumb (0.6.0 doc workspace — #208)
+ 'breadcrumb.aria': 'Breadcrumb',
+ 'breadcrumb.studio': 'Studio',
+ 'breadcrumb.mode.ask': 'Ask',
+ 'breadcrumb.mode.inspect': 'Inspect',
+ 'breadcrumb.mode.chunks': 'Chunks',
+
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
'comingSoon.title': 'Coming soon',
'comingSoon.subtitle.docsLibrary':