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).
196 lines
4.6 KiB
Vue
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>
|