docling-studio/frontend/src/features/reasoning/ui/ReasoningPanel.vue
Pier-Jean Malandrino 77fcb32e7f 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-27 11:48:32 +02:00

485 lines
12 KiB
Vue

<template>
<aside
v-if="store.hasTrace || store.importDialogOpen"
class="reasoning-panel"
data-e2e="reasoning-panel"
>
<header class="rp-header">
<h3>{{ t('reasoning.panelTitle') }}</h3>
<div class="rp-header-actions">
<button
class="rp-btn-ghost rp-btn-toggle"
:class="{ active: store.focusMode }"
:aria-pressed="store.focusMode"
data-e2e="reasoning-focus-toggle"
:title="t('reasoning.focusHint')"
@click="store.toggleFocusMode()"
>
<span class="rp-dot" />
{{ t('reasoning.focus') }}
</button>
<button class="rp-btn-ghost" @click="store.openImportDialog()">
{{ t('reasoning.reimport') }}
</button>
<button class="rp-btn-ghost" @click="onClear">{{ t('reasoning.clear') }}</button>
</div>
</header>
<section v-if="envelope" class="rp-meta">
<div v-if="envelope.query" class="rp-query">
<span class="rp-meta-label">{{ t('reasoning.query') }}</span>
<span class="rp-meta-value">{{ envelope.query }}</span>
</div>
<div class="rp-meta-row">
<span v-if="envelope.filename" class="rp-meta-chip">{{ envelope.filename }}</span>
<span v-if="envelope.model?.ollama_name" class="rp-meta-chip">
{{ envelope.model.ollama_name }}
</span>
</div>
</section>
<section v-if="result" class="rp-answer">
<div class="rp-answer-header">
<span class="rp-answer-label">{{ t('reasoning.answerLabel') }}</span>
<span class="rp-answer-actions">
<span class="rp-converged" :class="{ yes: result.converged, no: !result.converged }">
{{ result.converged ? t('reasoning.converged') : t('reasoning.notConverged') }}
</span>
<button
class="rp-copy-btn"
:title="t('reasoning.copyAnswer')"
data-e2e="reasoning-copy-answer"
@click="copyAnswer"
>
{{ copied ? t('reasoning.copied') : t('reasoning.copy') }}
</button>
</span>
</div>
<!-- eslint-disable-next-line vue/no-v-html -- sanitized by DOMPurify -->
<div class="rp-answer-body markdown-body" v-html="renderedAnswer" />
<div class="rp-answer-footer">
<span class="rp-stats">
{{ store.presentCount }} / {{ store.iterations.length }} {{ t('reasoning.resolved') }}
</span>
</div>
</section>
<section v-if="store.missingCount > 0" class="rp-warn" data-e2e="reasoning-missing-warn">
{{ missingWarning }}
</section>
<section class="rp-iterations">
<h4 class="rp-section-title">{{ t('reasoning.iterationsTitle') }}</h4>
<div v-if="store.iterations.length === 0" class="rp-empty">
{{ t('reasoning.noIterations') }}
</div>
<div v-else class="rp-iteration-list">
<IterationCard
v-for="it in store.iterations"
:key="it.iteration"
:iteration="it"
:active="store.activeIteration === it.iteration"
@focus="(n) => emit('iterationFocus', n)"
/>
</div>
</section>
</aside>
<ImportTraceDialog />
</template>
<script setup lang="ts">
import type { Core } from 'cytoscape'
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { computed, ref, watch } from 'vue'
import { useI18n } from '../../../shared/i18n'
import {
applyReasoningOverlay,
buildDegradedOverlay,
clearReasoningOverlay,
} from '../graphReasoningOverlay'
import { useReasoningStore } from '../store'
import IterationCard from './IterationCard.vue'
import ImportTraceDialog from './ImportTraceDialog.vue'
const props = defineProps<{
/**
* The live Cytoscape instance from the GraphView. May be `null` while the
* graph is loading or if Maintain hasn't been run for this document.
* Passed down from StudioPage via `graphViewRef.cy`.
*/
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()
const result = computed(() => store.rawResult)
const envelope = computed(() => store.envelope)
// Render the answer as markdown so numbered lists, bold, etc. render properly.
// Models tend to produce markdown-formatted answers (numbered lists especially),
// and plain-text `pre-wrap` made them near-unreadable.
const renderedAnswer = computed(() => {
const raw = result.value?.answer ?? ''
if (!raw.trim()) return ''
return DOMPurify.sanitize(marked.parse(raw, { async: false }) as string)
})
const copied = ref(false)
let copyResetTimer: ReturnType<typeof setTimeout> | null = null
async function copyAnswer(): Promise<void> {
const text = result.value?.answer
if (!text) return
try {
await navigator.clipboard.writeText(text)
copied.value = true
if (copyResetTimer) clearTimeout(copyResetTimer)
copyResetTimer = setTimeout(() => {
copied.value = false
}, 1800)
} catch (e) {
console.warn('Copy failed', e)
}
}
const missingWarning = computed(() => {
// Full miss + no cy → the graph simply isn't loaded. Different message
// than "N sections are actually missing from the graph".
if (!props.cy && store.missingCount > 0 && store.presentCount === 0) {
return t('reasoning.graphNotLoadedWarn')
}
return t('reasoning.missingWarn').replace('{n}', String(store.missingCount))
})
function reapplyOverlay(): void {
if (!store.rawResult) {
if (props.cy) clearReasoningOverlay(props.cy)
store.setOverlay(null)
return
}
// When the Cytoscape instance is available (graph loaded for this doc) we
// run the full overlay: mark visited nodes, draw REASONING_NEXT arrows.
// Otherwise (404 on the graph endpoint, or Maintain not run yet) we still
// build the iteration list in "degraded" mode so the user can read the
// reasoning — they just won't see nodes highlighted.
const out = props.cy
? applyReasoningOverlay(props.cy, store.rawResult, { focusMode: store.focusMode })
: buildDegradedOverlay(store.rawResult)
store.setOverlay(out)
}
// Reapply whenever cy, rawResult, or focusMode changes. This handles:
// - User imports trace after graph loaded (rawResult changes).
// - User navigates to a different doc which swaps cy (cy changes).
// - Graph loads AFTER the trace was already imported (cy null → non-null).
// - User toggles focus mode (focusMode changes) — dim in, dim out.
// - User clears the trace (rawResult → null → clearReasoningOverlay).
watch(
() => [props.cy, store.rawResult, store.focusMode] as const,
() => reapplyOverlay(),
{ immediate: true },
)
function onClear(): void {
if (props.cy) clearReasoningOverlay(props.cy)
store.reset()
}
</script>
<style scoped>
.reasoning-panel {
display: flex;
flex-direction: column;
gap: 12px;
width: 340px;
flex: 0 0 340px;
padding: 16px;
border-left: 1px solid var(--border);
background: var(--bg);
overflow-y: auto;
height: 100%;
}
.rp-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.rp-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.rp-header-actions {
display: flex;
gap: 4px;
}
.rp-btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
padding: 4px 8px;
font-size: 11px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.rp-btn-ghost:hover {
background: var(--border-light);
color: var(--text);
}
.rp-btn-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
}
.rp-btn-toggle .rp-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
transition: background var(--transition);
}
.rp-btn-toggle.active {
border-color: #ea580c;
color: #ea580c;
background: rgba(234, 88, 12, 0.08);
}
.rp-btn-toggle.active .rp-dot {
background: #ea580c;
}
.rp-meta {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.rp-meta-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
display: block;
margin-bottom: 2px;
}
.rp-meta-value {
font-size: 12px;
color: var(--text);
line-height: 1.4;
}
.rp-meta-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rp-meta-chip {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
background: var(--border-light);
color: var(--text-secondary);
font-size: 10px;
font-family: 'IBM Plex Mono', monospace;
}
.rp-answer {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
background: var(--bg);
border: 1px solid #ea580c;
border-radius: var(--radius);
box-shadow: 0 1px 3px rgba(234, 88, 12, 0.08);
}
.rp-answer-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.rp-answer-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
color: #ea580c;
}
.rp-answer-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.rp-converged {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rp-converged.yes {
background: rgba(22, 163, 74, 0.15);
color: #15803d;
}
.rp-converged.no {
background: rgba(234, 179, 8, 0.15);
color: #a16207;
}
.rp-copy-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 2px 8px;
font-size: 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.rp-copy-btn:hover {
background: var(--border-light);
color: var(--text);
}
.rp-stats {
font-size: 10px;
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
}
.rp-answer-footer {
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border-light);
padding-top: 6px;
}
/* Markdown-rendered answer body. Mirrors a subset of MarkdownViewer styles,
* tuned for a narrow right-rail context (tighter sizes than the full viewer). */
.rp-answer-body {
font-size: 13.5px;
line-height: 1.6;
color: var(--text);
}
.rp-answer-body :deep(p) {
margin: 0 0 8px;
}
.rp-answer-body :deep(p:last-child) {
margin-bottom: 0;
}
.rp-answer-body :deep(ol),
.rp-answer-body :deep(ul) {
margin: 4px 0 8px;
padding-left: 22px;
}
.rp-answer-body :deep(li) {
margin: 2px 0;
}
.rp-answer-body :deep(strong) {
color: var(--text);
font-weight: 600;
}
.rp-answer-body :deep(code) {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
background: var(--border-light);
padding: 1px 5px;
border-radius: 3px;
}
.rp-answer-body :deep(pre) {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
background: var(--border-light);
padding: 8px 10px;
border-radius: var(--radius-sm);
overflow-x: auto;
margin: 6px 0;
}
.rp-answer-body :deep(h1),
.rp-answer-body :deep(h2),
.rp-answer-body :deep(h3),
.rp-answer-body :deep(h4) {
margin: 10px 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.rp-answer-body :deep(a) {
color: #ea580c;
text-decoration: underline;
}
.rp-warn {
padding: 8px 10px;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.3);
border-radius: var(--radius-sm);
color: #a16207;
font-size: 12px;
}
.rp-section-title {
margin: 0 0 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
font-weight: 600;
}
.rp-iteration-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.rp-empty {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
</style>