docling-studio/frontend/src/features/reasoning/ui/DocumentView.vue
Pier-Jean Malandrino 84a10c472b fix(reasoning): re-scroll PDF when re-clicking the active iteration
The watch-based plumbing from iteration click to PDF scroll relied on a
"flip via null" pattern (assign null then the value) to coerce Vue into
re-firing the watcher. Vue 3 collapses synchronous mutations of the same
ref and only delivers the final value, so the trick was a no-op: a second
click on the same iteration left the document view stuck on the previous
page. The bug only showed when the trace had a single iteration — with
several, the user naturally clicks different ones and the value really
changes.

Replace the watch chain with imperative dispatch. ReasoningPanel now just
emits iterationFocus; ReasoningWorkspace handles it by calling the graph
focus and the new StructureViewer.scrollToFocused method directly. Both
side effects fire on every click regardless of state.
2026-04-29 14:00:00 +02:00

107 lines
3 KiB
Vue

<template>
<div class="rdv-root" data-e2e="reasoning-document-view">
<div v-if="!pages || pages.length === 0" class="rdv-empty">
{{ t('reasoning.docNoContent') }}
</div>
<StructureViewer
v-else
ref="structureViewerRef"
:pages="pages"
:document-id="docId"
:visited-by-self-ref="visitedBySelfRef"
:focused-self-ref="focusedSelfRef"
:dim-non-visited="reasoningStore.focusMode"
selectable
class="rdv-viewer"
@element-focus="(ref) => emit('elementFocus', ref)"
/>
</div>
</template>
<script setup lang="ts">
/**
* PDF rendering of the document (per-page PNG via /api/documents/:id/preview),
* augmented with reasoning overlays:
* - elements visited by the RAG loop get a bold orange stroke + numbered
* badge showing the visit order
* - the current `focusedSelfRef` (usually driven by a graph-node click
* upstream) auto-jumps to the right page and pulses its bbox
* - clicking a bbox emits `elementFocus` so the graph can mirror the
* selection (bidirectional sync handled in ReasoningWorkspace).
*/
import { computed, ref } from 'vue'
import StructureViewer from '../../analysis/ui/StructureViewer.vue'
import { useAnalysisStore } from '../../analysis/store'
import { useI18n } from '../../../shared/i18n'
import type { Page } from '../../../shared/types'
import { useReasoningStore } from '../store'
const props = defineProps<{
docId: string
focusedSelfRef: string | null
}>()
const emit = defineEmits<{ elementFocus: [selfRef: string] }>()
const structureViewerRef = ref<InstanceType<typeof StructureViewer> | null>(null)
// Imperative passthrough so the reasoning workspace can re-trigger a scroll
// on the same self_ref (e.g. user re-clicks the active iteration card —
// the prop hasn't changed so the watch wouldn't fire).
function scrollToFocused(selfRef: string | null): void {
structureViewerRef.value?.scrollToFocused(selfRef)
}
defineExpose({ scrollToFocused })
const analysisStore = useAnalysisStore()
const reasoningStore = useReasoningStore()
const { t } = useI18n()
const pages = computed<Page[]>(() => {
const hit = analysisStore.analyses.find(
(a) => a.documentId === props.docId && a.status === 'COMPLETED' && a.pagesJson,
)
if (!hit?.pagesJson) return []
try {
return JSON.parse(hit.pagesJson) as Page[]
} catch {
return []
}
})
const visitedBySelfRef = computed<Map<string, number>>(() => {
const out = new Map<string, number>()
for (const it of reasoningStore.iterations) {
if (!it.present || !it.sectionRef) continue
if (!out.has(it.sectionRef)) out.set(it.sectionRef, it.iteration)
}
return out
})
</script>
<style scoped>
.rdv-root {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow-y: auto;
background: var(--bg);
padding: 16px 20px;
}
.rdv-viewer {
max-width: 960px;
margin: 0 auto;
}
.rdv-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}
</style>