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 9ec64961fc
commit 77fcb32e7f
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 // 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 // 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. // overlay redraw will then show the dashed focus ring around its bbox.
watch( function scrollToFocused(ref: string | null): void {
() => props.focusedSelfRef, if (!ref) {
(ref) => { nextTick(drawOverlay)
if (!ref) { return
nextTick(drawOverlay) }
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 return
} }
for (const page of props.pages) { }
if (page.elements.some((e) => e.self_ref === ref)) { // Ref not on any page (e.g. a #/body node) just redraw to clear the
if (selectedPage.value !== page.page_number) { // previous focus ring.
selectedPage.value = page.page_number nextTick(drawOverlay)
// Let <img> reload before drawing drawOverlay runs on @load. }
} else {
nextTick(drawOverlay) watch(() => props.focusedSelfRef, scrollToFocused)
}
return // 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.
// Ref not on any page (e.g. a #/body node) just redraw to clear the defineExpose({ scrollToFocused })
// previous focus ring.
nextTick(drawOverlay)
},
)
</script> </script>
<style scoped> <style scoped>

View file

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

View file

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

View file

@ -79,7 +79,7 @@
:key="it.iteration" :key="it.iteration"
:iteration="it" :iteration="it"
:active="store.activeIteration === it.iteration" :active="store.activeIteration === it.iteration"
@focus="onFocus" @focus="(n) => emit('iterationFocus', n)"
/> />
</div> </div>
</section> </section>
@ -99,8 +99,6 @@ import {
applyReasoningOverlay, applyReasoningOverlay,
buildDegradedOverlay, buildDegradedOverlay,
clearReasoningOverlay, clearReasoningOverlay,
focusIteration,
nodeIdForSectionRef,
} from '../graphReasoningOverlay' } from '../graphReasoningOverlay'
import { useReasoningStore } from '../store' import { useReasoningStore } from '../store'
import IterationCard from './IterationCard.vue' import IterationCard from './IterationCard.vue'
@ -115,6 +113,11 @@ const props = defineProps<{
cy: Core | null 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 store = useReasoningStore()
const { t } = useI18n() const { t } = useI18n()
@ -186,14 +189,6 @@ watch(
{ immediate: true }, { 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 { function onClear(): void {
if (props.cy) clearReasoningOverlay(props.cy) if (props.cy) clearReasoningOverlay(props.cy)
store.reset() store.reset()

View file

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