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.
This commit is contained in:
Pier-Jean Malandrino 2026-04-27 11:48:32 +02:00
parent 8694353d8b
commit 84a10c472b
5 changed files with 67 additions and 57 deletions

View file

@ -284,29 +284,33 @@ watch(
// When the caller sets a focused self_ref (e.g. the user clicked a node in
// the graph), find which page that element lives on and jump to it. The
// overlay redraw will then show the dashed focus ring around its bbox.
watch(
() => props.focusedSelfRef,
(ref) => {
if (!ref) {
nextTick(drawOverlay)
function scrollToFocused(ref: string | null): void {
if (!ref) {
nextTick(drawOverlay)
return
}
for (const page of props.pages) {
if (page.elements.some((e) => e.self_ref === ref)) {
if (selectedPage.value !== page.page_number) {
selectedPage.value = page.page_number
// Let <img> reload before drawing drawOverlay runs on @load.
} else {
nextTick(drawOverlay)
}
return
}
for (const page of props.pages) {
if (page.elements.some((e) => e.self_ref === ref)) {
if (selectedPage.value !== page.page_number) {
selectedPage.value = page.page_number
// Let <img> reload before drawing drawOverlay runs on @load.
} else {
nextTick(drawOverlay)
}
return
}
}
// Ref not on any page (e.g. a #/body node) just redraw to clear the
// previous focus ring.
nextTick(drawOverlay)
},
)
}
// Ref not on any page (e.g. a #/body node) just redraw to clear the
// previous focus ring.
nextTick(drawOverlay)
}
watch(() => props.focusedSelfRef, scrollToFocused)
// Imperative entry point so callers can re-trigger a scroll on the same
// self_ref (the watch above only fires on value change). Used by the
// reasoning workspace when the user re-clicks the active iteration card.
defineExpose({ scrollToFocused })
</script>
<style scoped>

View file

@ -96,11 +96,10 @@ export const useReasoningStore = defineStore('reasoning', () => {
}
function setActiveIteration(n: number | null): void {
// Flip via null first so a click on the *same* iteration still fires
// downstream watchers (PDF scroll, graph pan). Otherwise Vue sees
// "no change" and the second click is a silent no-op — painful UX when
// only one iteration exists.
activeIteration.value = null
// Pure state — drives the active-card highlight in the iteration list.
// Side effects (graph pan, PDF scroll) are dispatched imperatively from
// ReasoningWorkspace.onIterationFocus so re-clicking the same iteration
// still re-focuses both views (a watch here would no-op on same value).
activeIteration.value = n
}

View file

@ -5,6 +5,7 @@
</div>
<StructureViewer
v-else
ref="structureViewerRef"
:pages="pages"
:document-id="docId"
:visited-by-self-ref="visitedBySelfRef"
@ -28,7 +29,7 @@
* - clicking a bbox emits `elementFocus` so the graph can mirror the
* selection (bidirectional sync handled in ReasoningWorkspace).
*/
import { computed } from 'vue'
import { computed, ref } from 'vue'
import StructureViewer from '../../analysis/ui/StructureViewer.vue'
import { useAnalysisStore } from '../../analysis/store'
@ -42,6 +43,17 @@ const props = defineProps<{
}>()
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()

View file

@ -79,7 +79,7 @@
:key="it.iteration"
:iteration="it"
:active="store.activeIteration === it.iteration"
@focus="onFocus"
@focus="(n) => emit('iterationFocus', n)"
/>
</div>
</section>
@ -99,8 +99,6 @@ import {
applyReasoningOverlay,
buildDegradedOverlay,
clearReasoningOverlay,
focusIteration,
nodeIdForSectionRef,
} from '../graphReasoningOverlay'
import { useReasoningStore } from '../store'
import IterationCard from './IterationCard.vue'
@ -115,6 +113,11 @@ const props = defineProps<{
cy: Core | null
}>()
// Iteration clicks bubble up to the workspace, which dispatches focus to
// both the graph and the PDF directly keeping the panel ignorant of its
// siblings and avoiding watch-based plumbing that misfires on repeat clicks.
const emit = defineEmits<{ iterationFocus: [iteration: number] }>()
const store = useReasoningStore()
const { t } = useI18n()
@ -186,14 +189,6 @@ watch(
{ immediate: true },
)
function onFocus(iteration: number): void {
store.setActiveIteration(iteration)
if (!props.cy) return
const hit = store.iterations.find((i) => i.iteration === iteration)
if (!hit || !hit.present) return
focusIteration(props.cy, nodeIdForSectionRef(hit.sectionRef))
}
function onClear(): void {
if (props.cy) clearReasoningOverlay(props.cy)
store.reset()

View file

@ -69,11 +69,12 @@
and the initial scroll never fires. -->
<DocumentView
v-show="mode === 'document'"
ref="documentViewRef"
:doc-id="docId"
:focused-self-ref="focusedSelfRef"
@element-focus="onPdfElementFocus"
/>
<ReasoningPanel :cy="graphCy" />
<ReasoningPanel :cy="graphCy" @iteration-focus="onIterationFocus" />
</div>
<RunReasoningDialog :doc-id="docId" :doc-filename="docFilename" />
@ -86,6 +87,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
import GraphView from '../../analysis/ui/GraphView.vue'
import { useI18n } from '../../../shared/i18n'
import { fetchReasoningGraph } from '../api'
import { focusIteration, nodeIdForSectionRef } from '../graphReasoningOverlay'
import { useReasoningStore } from '../store'
import DocumentView from './DocumentView.vue'
import ReasoningPanel from './ReasoningPanel.vue'
@ -105,6 +107,7 @@ const reasoningStore = useReasoningStore()
const graphViewRef = ref<InstanceType<typeof GraphView> | null>(null)
const graphCy = computed(() => graphViewRef.value?.cy ?? null)
const documentViewRef = ref<InstanceType<typeof DocumentView> | null>(null)
const mode = ref<WorkspaceMode>('graph')
@ -126,24 +129,21 @@ function onPdfElementFocus(selfRef: string): void {
graphViewRef.value?.selectBySelfRef(selfRef)
}
// Click on an iteration card in the reasoning panel flows through
// `reasoningStore.setActiveIteration(n)`. That path already focuses the
// cytoscape node (in ReasoningPanel.onFocus); we mirror it into the PDF
// viewer by resolving the active iteration's section_ref and piping it
// into our shared focus. Done at the workspace level not inside the
// panel because the panel doesn't know it has a PDF sibling.
watch(
() => reasoningStore.activeIteration,
(n) => {
if (n === null) return
const hit = reasoningStore.iterations.find((i) => i.iteration === n)
if (!hit?.present || !hit.sectionRef) return
// Flip via null so StructureViewer's watch on `focusedSelfRef` re-fires
// even when clicking the same iteration twice (same sectionRef).
focusedSelfRef.value = null
focusedSelfRef.value = hit.sectionRef
},
)
// Click on an iteration card in the reasoning panel. We dispatch focus to
// both the graph and the PDF imperatively (rather than via watches on
// `activeIteration` / `focusedSelfRef`). Watches misfire when the user
// re-clicks the same iteration: Vue collapses synchronous "flip via null"
// mutations and only sees the final value, equal to the previous one so
// nothing scrolls. Calling `focusIteration` and `scrollToFocused` directly
// works on every click regardless of state.
function onIterationFocus(iteration: number): void {
reasoningStore.setActiveIteration(iteration)
const hit = reasoningStore.iterations.find((i) => i.iteration === iteration)
if (!hit?.present || !hit.sectionRef) return
if (graphCy.value) focusIteration(graphCy.value, nodeIdForSectionRef(hit.sectionRef))
focusedSelfRef.value = hit.sectionRef
documentViewRef.value?.scrollToFocused(hit.sectionRef)
}
// Reset the reasoning store when switching docs a trace imported for one
// document is meaningless on another. The main-pane mode resets too so a