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
|
- `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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user