From 3b72919015b1e8cc0e3e2f341404f9259576d133 Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Sat, 21 Feb 2026 12:55:36 -0300 Subject: [PATCH] Fix buffered processing log typing sequence --- doc/README.md | 2 +- doc/frontend-design-foundation.md | 7 + .../src/components/ProcessingLogPanel.tsx | 168 ++++++++++++++++-- 3 files changed, 158 insertions(+), 19 deletions(-) diff --git a/doc/README.md b/doc/README.md index 8fcf45b..1065d48 100644 --- a/doc/README.md +++ b/doc/README.md @@ -9,4 +9,4 @@ This directory contains technical documentation for DMS. - `api-contract.md` - API endpoint contract grouped by route module, including settings and processing-log trim defaults - `data-model-reference.md` - database entity definitions and lifecycle states - `operations-and-configuration.md` - runtime operations, ports, volumes, and persisted settings configuration -- `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, and settings helper-copy guidance +- `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, processing-log timeline behavior, and settings helper-copy guidance diff --git a/doc/frontend-design-foundation.md b/doc/frontend-design-foundation.md index ca52093..35016f0 100644 --- a/doc/frontend-design-foundation.md +++ b/doc/frontend-design-foundation.md @@ -42,6 +42,13 @@ Do not hardcode new palette or spacing values in component styles when a token a - Keep transitions brief and functional. - Avoid decorative animation loops outside explicit status indicators like terminal caret blink. +## Processing Log Timeline Behavior + +- Keep processing log headers in strict arrival order when typing animation is enabled. +- Buffer newly discovered entries and reveal only one active line at a time while it types. +- Do not render queued headers before their animation starts, even when polling returns batched updates. +- Preserve existing header content format and fold/unfold detail behavior as lines are revealed. + ## Extension Checklist When adding or redesigning a UI area: diff --git a/frontend/src/components/ProcessingLogPanel.tsx b/frontend/src/components/ProcessingLogPanel.tsx index 09b6230..62fad32 100644 --- a/frontend/src/components/ProcessingLogPanel.tsx +++ b/frontend/src/components/ProcessingLogPanel.tsx @@ -6,18 +6,45 @@ import type { JSX } from 'react'; import type { ProcessingLogEntry } from '../types'; +/** + * Input contract for the processing timeline panel. + */ interface ProcessingLogPanelProps { + /** + * Raw timeline entries returned by the API. + */ entries: ProcessingLogEntry[]; + /** + * Indicates that the parent screen is currently refreshing timeline data. + */ isLoading: boolean; + /** + * Indicates that the timeline clear action is currently pending. + */ isClearing: boolean; + /** + * Currently selected document ID used for header highlighting. + */ selectedDocumentId: string | null; + /** + * Indicates whether processing work is currently active for the workspace. + */ isProcessingActive: boolean; + /** + * Enables typed header rendering for newly queued entries. + */ typingAnimationEnabled: boolean; + /** + * Clears persisted processing logs. + */ onClear: () => void; } /** - * Renders processing events in a terminal-style stream with optional typed headers. + * Renders processing events in a terminal-style stream with buffered sequential typing. + * + * New entries are queued and revealed one by one so batched timeline updates never render + * full headers before the animation for that entry has completed. */ export default function ProcessingLogPanel({ entries, @@ -29,11 +56,15 @@ export default function ProcessingLogPanel({ onClear, }: ProcessingLogPanelProps): JSX.Element { const timeline = useMemo(() => [...entries].reverse(), [entries]); - const [typedEntryIds, setTypedEntryIds] = useState>(() => new Set()); + const timelineById = useMemo(() => new Map(timeline.map((entry) => [entry.id, entry])), [timeline]); + const [revealedEntryIds, setRevealedEntryIds] = useState>(() => new Set()); + const [pendingEntryIds, setPendingEntryIds] = useState([]); const [typingEntryId, setTypingEntryId] = useState(null); const [typingHeader, setTypingHeader] = useState(''); const [expandedIds, setExpandedIds] = useState>(() => new Set()); const timerRef = useRef(null); + const isBootstrapCompleteRef = useRef(false); + const knownEntryIdsRef = useRef>(new Set()); const formatTimestamp = (value: string): string => { const parsed = new Date(value); @@ -63,28 +94,120 @@ export default function ProcessingLogPanel({ }; useEffect(() => { - const knownIds = new Set(typedEntryIds); - if (typingEntryId !== null) { - knownIds.add(typingEntryId); + const timelineIdSet = new Set(timeline.map((entry) => entry.id)); + + knownEntryIdsRef.current.forEach((entryId) => { + if (!timelineIdSet.has(entryId)) { + knownEntryIdsRef.current.delete(entryId); + } + }); + + setRevealedEntryIds((current) => { + const next = new Set(); + current.forEach((entryId) => { + if (timelineIdSet.has(entryId)) { + next.add(entryId); + } + }); + return next; + }); + setPendingEntryIds((current) => current.filter((entryId) => timelineIdSet.has(entryId))); + setExpandedIds((current) => { + const next = new Set(); + current.forEach((entryId) => { + if (timelineIdSet.has(entryId)) { + next.add(entryId); + } + }); + return next; + }); + + if (typingEntryId !== null && !timelineIdSet.has(typingEntryId)) { + if (timerRef.current !== null) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + setTypingEntryId(null); + setTypingHeader(''); } - const nextUntyped = timeline.find((entry) => !knownIds.has(entry.id)); - if (!nextUntyped) { + + if (!isBootstrapCompleteRef.current) { + if (timeline.length === 0) { + return; + } + isBootstrapCompleteRef.current = true; + if (timeline.length > 1) { + timeline.forEach((entry) => { + knownEntryIdsRef.current.add(entry.id); + }); + setRevealedEntryIds(new Set(timeline.map((entry) => entry.id))); + return; + } + } + + const discoveredEntryIds: number[] = []; + timeline.forEach((entry) => { + if (!knownEntryIdsRef.current.has(entry.id)) { + knownEntryIdsRef.current.add(entry.id); + discoveredEntryIds.push(entry.id); + } + }); + + if (discoveredEntryIds.length > 0) { + setPendingEntryIds((current) => [...current, ...discoveredEntryIds]); + } + }, [timeline, typingEntryId]); + + useEffect(() => { + if (typingAnimationEnabled) { return; } + if (typingEntryId === null && pendingEntryIds.length === 0) { + return; + } + if (timerRef.current !== null) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + setRevealedEntryIds((current) => { + const next = new Set(current); + if (typingEntryId !== null) { + next.add(typingEntryId); + } + pendingEntryIds.forEach((entryId) => { + next.add(entryId); + }); + return next; + }); + setTypingEntryId(null); + setTypingHeader(''); + setPendingEntryIds([]); + }, [pendingEntryIds, typingAnimationEnabled, typingEntryId]); + + useEffect(() => { + if (typingEntryId !== null || pendingEntryIds.length === 0) { + return; + } + + const nextEntryId = pendingEntryIds[0]; + setPendingEntryIds((current) => current.slice(1)); + if (!typingAnimationEnabled) { - setTypedEntryIds((current) => { + setRevealedEntryIds((current) => { const next = new Set(current); - next.add(nextUntyped.id); + next.add(nextEntryId); return next; }); return; } - if (typingEntryId !== null) { + + const nextEntry = timelineById.get(nextEntryId); + if (!nextEntry) { return; } - const fullHeader = renderHeader(nextUntyped); - setTypingEntryId(nextUntyped.id); + const fullHeader = renderHeader(nextEntry); + setTypingEntryId(nextEntryId); setTypingHeader(''); let cursor = 0; timerRef.current = window.setInterval(() => { @@ -95,15 +218,16 @@ export default function ProcessingLogPanel({ window.clearInterval(timerRef.current); timerRef.current = null; } - setTypedEntryIds((current) => { + setRevealedEntryIds((current) => { const next = new Set(current); - next.add(nextUntyped.id); + next.add(nextEntryId); return next; }); setTypingEntryId(null); + setTypingHeader(''); } }, 10); - }, [timeline, typedEntryIds, typingAnimationEnabled, typingEntryId]); + }, [pendingEntryIds, renderHeader, timelineById, typingAnimationEnabled, typingEntryId]); useEffect(() => { return () => { @@ -113,6 +237,14 @@ export default function ProcessingLogPanel({ }; }, []); + const visibleTimeline = useMemo(() => { + const visibleEntryIds = new Set(revealedEntryIds); + if (typingEntryId !== null) { + visibleEntryIds.add(typingEntryId); + } + return timeline.filter((entry) => visibleEntryIds.has(entry.id)); + }, [revealedEntryIds, timeline, typingEntryId]); + return (
@@ -127,12 +259,12 @@ export default function ProcessingLogPanel({
{timeline.length === 0 &&

No processing events yet.

} - {timeline.map((entry, index) => { + {visibleTimeline.map((entry, index) => { const groupKey = `${entry.document_id ?? 'unbound'}:${entry.stage}`; - const previousGroupKey = index > 0 ? `${timeline[index - 1].document_id ?? 'unbound'}:${timeline[index - 1].stage}` : null; + const previousGroupKey = index > 0 ? `${visibleTimeline[index - 1].document_id ?? 'unbound'}:${visibleTimeline[index - 1].stage}` : null; const showSeparator = index > 0 && groupKey !== previousGroupKey; const isTyping = entry.id === typingEntryId; - const isTyped = typedEntryIds.has(entry.id) || (!typingAnimationEnabled && !isTyping); + const isTyped = revealedEntryIds.has(entry.id) || (!typingAnimationEnabled && !isTyping); const isExpanded = expandedIds.has(entry.id); const providerModel = [entry.provider_id, entry.model_name].filter(Boolean).join(' / '); const hasDetails =