docling-studio/frontend/src/pages/DocWorkspacePage.vue
Pier-Jean Malandrino b4ad874600 fix(#256): drop pushDocumentToStore duplicate, align rechunk return shape
The frontend had two endpoints pointing at the same operation:
  - features/document/api.ts:pushDocumentToStore  → POST /push
  - features/chunks/api.ts:pushChunksToStore      → POST /chunks/push

Backend now exposes a single /chunks/push route (semantically more
accurate — it's chunks that get pushed, not the doc itself), so the
document-side variant is removed. document/store.ts:pushToStore now
delegates to chunks/api.

rechunkDocument returned {jobId} based on the old async-job model.
The new backend rechunk runs synchronously and returns the chunk
list directly. The DocsLibraryPage caller now reports a count
(via new docs.rechunkDone i18n key) instead of a fake job id.

Adds data-e2e=tab-{mode} on the doc workspace tab strip so the
new Karate scenario can target the chunks tab without text matching.

Extends chunks/api.test.ts with HTTP error-propagation guards on
every call — the original 404 bug would have surfaced in CI.
2026-05-11 15:00:57 +02:00

250 lines
6.2 KiB
Vue

<template>
<div class="workspace-page">
<!-- Loading doc -->
<div v-if="loadingDoc" class="workspace-loading">
<span class="spinner" />
</div>
<!-- Doc error -->
<div v-else-if="docError" class="workspace-error">
<p>{{ docError }}</p>
<RouterLink :to="{ name: ROUTES.DOCS_LIBRARY }" class="back-link">
{{ t('workspace.backToLibrary') }}
</RouterLink>
</div>
<template v-else-if="doc">
<!-- Sticky header (#218) -->
<DocWorkspaceHeader :doc="doc" />
<!-- Tab strip (#216) -->
<div class="tab-strip" role="tablist" data-e2e="tab-strip">
<button
v-for="m in ALL_MODES"
:key="m"
class="tab-btn"
:class="{ active: activeMode === m, disabled: !modeEnabled(m) }"
role="tab"
:aria-selected="activeMode === m"
:disabled="!modeEnabled(m)"
:title="!modeEnabled(m) ? t('workspace.modeDisabled') : undefined"
:data-e2e="`tab-${m}`"
@click="switchMode(m)"
>
{{ t(`workspace.tabs.${m}`) }}
</button>
</div>
<!-- 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. -->
<div class="tab-content" role="tabpanel" data-e2e="tab-content">
<Suspense>
<DocChunksTab
v-if="activeMode === 'chunks'"
: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>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
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 { resolveMode } from '../shared/routing/resolveMode'
import { useCrumbs } from '../shared/breadcrumb/store'
import { truncate } from '../shared/breadcrumb/text'
import type { Crumb } from '../shared/breadcrumb/types'
import { useI18n } from '../shared/i18n'
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 }>()
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const flagStore = useFeatureFlagStore()
const doc = ref<Document | null>(null)
const loadingDoc = ref(true)
const docError = ref<string | null>(null)
const activeMode = ref<DocMode>(props.mode)
const crumbs = computed<Crumb[]>(() => [
{ kind: 'link', label: t('breadcrumb.studio'), to: { name: ROUTES.HOME } },
{
kind: 'link',
label: doc.value ? truncate(doc.value.filename, 40) : truncate(props.id, 40),
to: { name: ROUTES.DOC_WORKSPACE, params: { id: props.id } },
},
{ kind: 'leaf', label: t(`breadcrumb.mode.${activeMode.value}`) },
])
useCrumbs(crumbs)
function modeEnabled(m: DocMode): boolean {
return flagStore.modeFlags()[m]
}
function switchMode(m: DocMode): void {
if (!modeEnabled(m)) return
activeMode.value = m
router.replace({ query: { ...route.query, mode: m } })
}
async function loadDoc(): Promise<void> {
loadingDoc.value = true
docError.value = null
doc.value = null
const requestedId = props.id
try {
const fetched = await fetchDocument(requestedId)
if (requestedId !== props.id) return
doc.value = fetched
} catch (e) {
if (requestedId !== props.id) return
docError.value = (e as Error).message || 'Failed to load document'
} finally {
if (requestedId === props.id) loadingDoc.value = false
}
}
onMounted(async () => {
await flagStore.load()
const flags = flagStore.modeFlags()
const resolved = resolveMode(props.mode, flags)
if (!resolved) {
router.replace({ name: ROUTES.DOCS_LIBRARY, query: { reason: 'no-mode-enabled' } })
return
}
if (resolved !== props.mode) {
router.replace({ query: { ...route.query, mode: resolved } })
}
activeMode.value = resolved
await loadDoc()
})
watch(
() => props.mode,
(m) => {
const flags = flagStore.modeFlags()
const resolved = resolveMode(m, flags)
if (resolved) activeMode.value = resolved
},
)
watch(
() => props.id,
(newId, oldId) => {
if (newId !== oldId) loadDoc()
},
)
</script>
<style scoped>
.workspace-page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.workspace-loading,
.workspace-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: var(--text-muted);
font-size: 13px;
}
.workspace-error {
color: var(--error);
}
.back-link {
font-size: 13px;
color: var(--text-secondary);
text-decoration: none;
}
.back-link:hover {
color: var(--text);
}
.tab-strip {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
padding: 0 20px;
flex-shrink: 0;
}
.tab-btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all var(--transition);
margin-bottom: -1px;
}
.tab-btn:hover:not(.disabled) {
color: var(--text);
}
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-btn.disabled {
opacity: 0.35;
cursor: not-allowed;
}
.tab-content {
flex: 1;
overflow: hidden;
}
.spinner {
width: 28px;
height: 28px;
border: 2px solid var(--border-light);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>