Fix buffered processing log typing sequence
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<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 [typingHeader, setTypingHeader] = useState<string>('');
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const isBootstrapCompleteRef = useRef<boolean>(false);
|
||||
const knownEntryIdsRef = useRef<Set<number>>(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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (!typingAnimationEnabled) {
|
||||
setTypedEntryIds((current) => {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
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 (
|
||||
<section className="processing-log-panel">
|
||||
<div className="panel-header">
|
||||
@@ -127,12 +259,12 @@ export default function ProcessingLogPanel({
|
||||
<div className="processing-log-terminal-wrap">
|
||||
<div className="processing-log-terminal">
|
||||
{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 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 =
|
||||
|
||||
Reference in New Issue
Block a user