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:
parent
8694353d8b
commit
84a10c472b
5 changed files with 67 additions and 57 deletions
|
|
@ -284,9 +284,7 @@ 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) => {
|
||||
function scrollToFocused(ref: string | null): void {
|
||||
if (!ref) {
|
||||
nextTick(drawOverlay)
|
||||
return
|
||||
|
|
@ -305,8 +303,14 @@ watch(
|
|||
// 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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// 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
|
||||
// Flip via null so StructureViewer's watch on `focusedSelfRef` re-fires
|
||||
// even when clicking the same iteration twice (same sectionRef).
|
||||
focusedSelfRef.value = null
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue