docling-studio/frontend/src/pages/DocAskTab.vue
Pier-Jean Malandrino 3c80d8baf0 fix(workspace): reset doc state on navigation (bbox / analysis state leak)
Quand l'utilisateur naviguait d'un doc à un autre via /docs/:id, Vue
réutilisait l'instance du composant — onMounted ne se redéclenchait pas,
laissant l'analyse précédente (et donc les bbox du StructureViewer)
visibles tant que le nouveau fetch n'était pas terminé.

- DocWorkspacePage: watch props.id pour recharger le doc + :key sur les
  onglets (forçe un remount propre, reset l'état interne du
  StructureViewer comme selectedPage/hiddenTypes).
- DocInspectTab / DocAskTab: watch props.docId avec immediate:true,
  reset l'état au début du load + guard de race condition (ignore les
  réponses pour un docId obsolète).
2026-04-30 15:30:23 +02:00

196 lines
4.6 KiB
Vue

<template>
<div class="ask-tab" data-e2e="ask-tab">
<!-- Loading analysis -->
<div v-if="loading" class="ask-state">
<span class="spinner" />
</div>
<!-- Error -->
<div v-else-if="error" class="ask-state ask-state--error">
<p>{{ error }}</p>
<button class="retry-btn" @click="loadAnalysis">{{ t('inspect.retry') }}</button>
</div>
<!-- No analysis -->
<div v-else-if="pages.length === 0" class="ask-state">
<p class="ask-empty-title">{{ t('ask.noAnalysis') }}</p>
<p class="ask-empty-sub">{{ t('ask.noAnalysisSub') }}</p>
<RouterLink :to="{ name: ROUTES.STUDIO }" class="ask-cta">
{{ t('inspect.goToStudio') }}
</RouterLink>
</div>
<!-- Split layout: document left + ask panel right -->
<template v-else>
<div class="ask-doc-pane">
<StructureViewer
ref="structureViewerRef"
:pages="pages"
:document-id="docId"
:visited-by-self-ref="visitedBySelfRef"
:focused-self-ref="focusedSelfRef"
selectable
/>
</div>
<div class="ask-panel-pane">
<AskRunner :doc-id="docId" @section-focus="onSectionFocus" />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { RouterLink } from 'vue-router'
import type { Analysis, Page } from '../shared/types'
import { fetchDocumentAnalyses } from '../features/analysis/api'
import StructureViewer from '../features/analysis/ui/StructureViewer.vue'
import AskRunner from '../features/reasoning/ui/AskRunner.vue'
import { useI18n } from '../shared/i18n'
import { ROUTES } from '../shared/routing/names'
const props = defineProps<{ docId: string }>()
const { t } = useI18n()
const loading = ref(false)
const error = ref<string | null>(null)
const analysis = ref<Analysis | null>(null)
const focusedSelfRef = ref<string | null>(null)
const structureViewerRef = ref<InstanceType<typeof StructureViewer> | null>(null)
const pages = computed<Page[]>(() => {
if (!analysis.value?.pagesJson) return []
try {
return JSON.parse(analysis.value.pagesJson) as Page[]
} catch {
return []
}
})
const visitedBySelfRef = computed<Map<string, number>>(() => new Map())
async function loadAnalysis(): Promise<void> {
loading.value = true
error.value = null
analysis.value = null
focusedSelfRef.value = null
const requestedId = props.docId
try {
const analyses = await fetchDocumentAnalyses(requestedId)
if (requestedId !== props.docId) return
analysis.value = analyses.find((a) => a.status === 'COMPLETED') ?? null
} catch (e) {
if (requestedId !== props.docId) return
error.value = (e as Error).message || 'Failed to load analysis'
} finally {
if (requestedId === props.docId) loading.value = false
}
}
function onSectionFocus(sectionRef: string): void {
if (focusedSelfRef.value === sectionRef) {
structureViewerRef.value?.scrollToFocused(sectionRef)
} else {
focusedSelfRef.value = sectionRef
}
}
watch(() => props.docId, loadAnalysis, { immediate: true })
</script>
<style scoped>
.ask-tab {
display: flex;
height: 100%;
overflow: hidden;
}
.ask-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
gap: 12px;
color: var(--text-muted);
font-size: 13px;
}
.ask-state--error {
color: var(--error);
}
.ask-empty-title {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
margin: 0;
}
.ask-empty-sub {
font-size: 12px;
color: var(--text-muted);
margin: 0;
}
.ask-cta {
font-size: 13px;
color: var(--accent);
text-decoration: none;
border: 1px solid var(--accent);
padding: 6px 14px;
border-radius: var(--radius-sm);
transition: all var(--transition);
}
.ask-cta:hover {
background: var(--accent-muted);
}
.retry-btn {
font-size: 13px;
color: var(--text-secondary);
background: none;
border: 1px solid var(--border);
padding: 6px 14px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.retry-btn:hover {
border-color: var(--text-secondary);
color: var(--text);
}
.ask-doc-pane {
flex: 1;
min-width: 0;
overflow-y: auto;
border-right: 1px solid var(--border);
padding: 16px 20px;
}
.ask-panel-pane {
width: 360px;
flex-shrink: 0;
overflow-y: auto;
background: var(--bg-surface);
}
.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>