Fix buffered processing log typing sequence

This commit is contained in:
2026-02-21 12:55:36 -03:00
parent 859acc133c
commit 3b72919015
3 changed files with 158 additions and 19 deletions

View File

@@ -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 - `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 - `data-model-reference.md` - database entity definitions and lifecycle states
- `operations-and-configuration.md` - runtime operations, ports, volumes, and persisted settings configuration - `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

View File

@@ -42,6 +42,13 @@ Do not hardcode new palette or spacing values in component styles when a token a
- Keep transitions brief and functional. - Keep transitions brief and functional.
- Avoid decorative animation loops outside explicit status indicators like terminal caret blink. - 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 ## Extension Checklist
When adding or redesigning a UI area: When adding or redesigning a UI area:

View File

@@ -6,18 +6,45 @@ import type { JSX } from 'react';
import type { ProcessingLogEntry } from '../types'; import type { ProcessingLogEntry } from '../types';
/**
* Input contract for the processing timeline panel.
*/
interface ProcessingLogPanelProps { interface ProcessingLogPanelProps {
/**
* Raw timeline entries returned by the API.
*/
entries: ProcessingLogEntry[]; entries: ProcessingLogEntry[];
/**
* Indicates that the parent screen is currently refreshing timeline data.
*/
isLoading: boolean; isLoading: boolean;
/**
* Indicates that the timeline clear action is currently pending.
*/
isClearing: boolean; isClearing: boolean;
/**
* Currently selected document ID used for header highlighting.
*/
selectedDocumentId: string | null; selectedDocumentId: string | null;
/**
* Indicates whether processing work is currently active for the workspace.
*/
isProcessingActive: boolean; isProcessingActive: boolean;
/**
* Enables typed header rendering for newly queued entries.
*/
typingAnimationEnabled: boolean; typingAnimationEnabled: boolean;
/**
* Clears persisted processing logs.
*/
onClear: () => void; 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({ export default function ProcessingLogPanel({
entries, entries,
@@ -29,11 +56,15 @@ export default function ProcessingLogPanel({
onClear, onClear,
}: ProcessingLogPanelProps): JSX.Element { }: ProcessingLogPanelProps): JSX.Element {
const timeline = useMemo(() => [...entries].reverse(), [entries]); const timeline = useMemo(() => [...entries].reverse(), [entries]);
const [typedEntryIds, setTypedEntryIds] = useState<Set<number>>(() => new Set()); const timelineById = useMemo(() => new Map(timeline.map((entry) => [entry.id, entry])), [timeline]);
const [revealedEntryIds, setRevealedEntryIds] = useState<Set<number>>(() => new Set());
const [pendingEntryIds, setPendingEntryIds] = useState<number[]>([]);
const [typingEntryId, setTypingEntryId] = useState<number | null>(null); const [typingEntryId, setTypingEntryId] = useState<number | null>(null);
const [typingHeader, setTypingHeader] = useState<string>(''); const [typingHeader, setTypingHeader] = useState<string>('');
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set()); const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
const timerRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const isBootstrapCompleteRef = useRef<boolean>(false);
const knownEntryIdsRef = useRef<Set<number>>(new Set());
const formatTimestamp = (value: string): string => { const formatTimestamp = (value: string): string => {
const parsed = new Date(value); const parsed = new Date(value);
@@ -63,28 +94,120 @@ export default function ProcessingLogPanel({
}; };
useEffect(() => { useEffect(() => {
const knownIds = new Set(typedEntryIds); const timelineIdSet = new Set(timeline.map((entry) => entry.id));
if (typingEntryId !== null) {
knownIds.add(typingEntryId); knownEntryIdsRef.current.forEach((entryId) => {
if (!timelineIdSet.has(entryId)) {
knownEntryIdsRef.current.delete(entryId);
} }
const nextUntyped = timeline.find((entry) => !knownIds.has(entry.id)); });
if (!nextUntyped) {
setRevealedEntryIds((current) => {
const next = new Set<number>();
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<number>();
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('');
}
if (!isBootstrapCompleteRef.current) {
if (timeline.length === 0) {
return; return;
} }
if (!typingAnimationEnabled) { isBootstrapCompleteRef.current = true;
setTypedEntryIds((current) => { 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); const next = new Set(current);
next.add(nextUntyped.id); 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) {
setRevealedEntryIds((current) => {
const next = new Set(current);
next.add(nextEntryId);
return next; return next;
}); });
return; return;
} }
if (typingEntryId !== null) {
const nextEntry = timelineById.get(nextEntryId);
if (!nextEntry) {
return; return;
} }
const fullHeader = renderHeader(nextUntyped); const fullHeader = renderHeader(nextEntry);
setTypingEntryId(nextUntyped.id); setTypingEntryId(nextEntryId);
setTypingHeader(''); setTypingHeader('');
let cursor = 0; let cursor = 0;
timerRef.current = window.setInterval(() => { timerRef.current = window.setInterval(() => {
@@ -95,15 +218,16 @@ export default function ProcessingLogPanel({
window.clearInterval(timerRef.current); window.clearInterval(timerRef.current);
timerRef.current = null; timerRef.current = null;
} }
setTypedEntryIds((current) => { setRevealedEntryIds((current) => {
const next = new Set(current); const next = new Set(current);
next.add(nextUntyped.id); next.add(nextEntryId);
return next; return next;
}); });
setTypingEntryId(null); setTypingEntryId(null);
setTypingHeader('');
} }
}, 10); }, 10);
}, [timeline, typedEntryIds, typingAnimationEnabled, typingEntryId]); }, [pendingEntryIds, renderHeader, timelineById, typingAnimationEnabled, typingEntryId]);
useEffect(() => { useEffect(() => {
return () => { 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 ( return (
<section className="processing-log-panel"> <section className="processing-log-panel">
<div className="panel-header"> <div className="panel-header">
@@ -127,12 +259,12 @@ export default function ProcessingLogPanel({
<div className="processing-log-terminal-wrap"> <div className="processing-log-terminal-wrap">
<div className="processing-log-terminal"> <div className="processing-log-terminal">
{timeline.length === 0 && <p className="terminal-empty">No processing events yet.</p>} {timeline.length === 0 && <p className="terminal-empty">No processing events yet.</p>}
{timeline.map((entry, index) => { {visibleTimeline.map((entry, index) => {
const groupKey = `${entry.document_id ?? 'unbound'}:${entry.stage}`; 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 showSeparator = index > 0 && groupKey !== previousGroupKey;
const isTyping = entry.id === typingEntryId; 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 isExpanded = expandedIds.has(entry.id);
const providerModel = [entry.provider_id, entry.model_name].filter(Boolean).join(' / '); const providerModel = [entry.provider_id, entry.model_name].filter(Boolean).join(' / ');
const hasDetails = const hasDetails =