Fix chat typing experience and Persian ordered list numbering

- Eliminate slow character-by-character typewriter delay on completed responses; render AI responses smoothly and immediately.
- Fix Persian/RTL list marker orientation by isolating numbers and placing delimiter in natural RTL flow (display 1. instead of .1).
- Ensure standard ordered lists in chat have explicit decimal numbering.
This commit is contained in:
ar.badiei
2026-10-15 19:56:00 +03:30
parent a3a9f2664b
commit d2a52a2958
3 changed files with 26 additions and 96 deletions
+12 -7
View File
@@ -20,7 +20,7 @@ type MessageBubbleProps = {
type ContentBlock = type ContentBlock =
| { type: 'p'; text: string } | { type: 'p'; text: string }
| { type: 'heading'; text: string } | { type: 'heading'; text: string }
| { type: 'list'; ordered: boolean; items: string[]; markers?: string[] } | { type: 'list'; ordered: boolean; items: string[]; markers?: string[]; delimiters?: string[] }
| { type: 'table'; headers: string[]; rows: string[][] }; | { type: 'table'; headers: string[]; rows: string[][] };
const stripImageMarkdown = (text: string): string => const stripImageMarkdown = (text: string): string =>
@@ -132,16 +132,16 @@ const isTableLine = (line: string): boolean =>
line.includes('|') && line.trim().split('|').filter(Boolean).length >= 2; line.includes('|') && line.trim().split('|').filter(Boolean).length >= 2;
/** Western, Persian, or Arabic-Indic digits + . or ) */ /** Western, Persian, or Arabic-Indic digits + . or ) */
const ORDERED_ITEM_RE = /^\s*((?:\d+|[۰-۹]+|[٠-٩]+))[.)]\s+(\S.*)$/; const ORDERED_ITEM_RE = /^\s*((?:\d+|[۰-۹]+|[٠-٩]+))([.)])\s+(\S.*)$/;
const isOrderedItem = (line: string): boolean => ORDERED_ITEM_RE.test(line); const isOrderedItem = (line: string): boolean => ORDERED_ITEM_RE.test(line);
const isBulletItem = (line: string): boolean => /^\s*(?:[-•]|[*])\s+\S/.test(line); const isBulletItem = (line: string): boolean => /^\s*(?:[-•]|[*])\s+\S/.test(line);
const parseOrderedItem = (line: string): { marker: string; text: string } | null => { const parseOrderedItem = (line: string): { marker: string; delimiter: string; text: string } | null => {
const match = line.match(ORDERED_ITEM_RE); const match = line.match(ORDERED_ITEM_RE);
if (!match) return null; if (!match) return null;
return { marker: match[1] ?? '', text: match[2] ?? '' }; return { marker: match[1] ?? '', delimiter: match[2] ?? '.', text: match[3] ?? '' };
}; };
const stripListMarker = (line: string): string => const stripListMarker = (line: string): string =>
@@ -201,6 +201,7 @@ const parseBlocks = (text: string): ContentBlock[] => {
const ordered = isOrderedItem(line); const ordered = isOrderedItem(line);
const items: string[] = []; const items: string[] = [];
const markers: string[] = []; const markers: string[] = [];
const delimiters: string[] = [];
while (i < lines.length) { while (i < lines.length) {
const current = lines[i] ?? ''; const current = lines[i] ?? '';
if (!current.trim()) break; if (!current.trim()) break;
@@ -210,6 +211,7 @@ const parseBlocks = (text: string): ContentBlock[] => {
if (!parsed) break; if (!parsed) break;
// Keep the model's own numbering (۱/۲/۳…) — do not renumber with CSS 1,2,3. // Keep the model's own numbering (۱/۲/۳…) — do not renumber with CSS 1,2,3.
markers.push(parsed.marker); markers.push(parsed.marker);
delimiters.push(parsed.delimiter);
items.push(parsed.text); items.push(parsed.text);
} else { } else {
items.push(stripListMarker(current)); items.push(stripListMarker(current));
@@ -217,7 +219,9 @@ const parseBlocks = (text: string): ContentBlock[] => {
i += 1; i += 1;
} }
blocks.push( blocks.push(
ordered ? { type: 'list', ordered: true, items, markers } : { type: 'list', ordered: false, items } ordered
? { type: 'list', ordered: true, items, markers, delimiters }
: { type: 'list', ordered: false, items }
); );
continue; continue;
} }
@@ -351,8 +355,9 @@ export const MessageBubble = ({
> >
{block.ordered ? ( {block.ordered ? (
<> <>
<span className="chat-num shrink-0 font-semibold" dir="ltr"> <span className="chat-num shrink-0 font-semibold select-none">
{block.markers?.[itemIndex] ?? String(itemIndex + 1)}. <span dir="ltr">{block.markers?.[itemIndex] ?? String(itemIndex + 1)}</span>
{block.delimiters?.[itemIndex] ?? '.'}
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
{renderInline(item, isActivelyTyping)} {renderInline(item, isActivelyTyping)}
+10 -89
View File
@@ -8,23 +8,10 @@ type UseStreamTypewriterOptions = {
onComplete?: () => void; onComplete?: () => void;
}; };
/** ~20% faster than the original pacing (delays × 0.8). */
const SPEED = 0.8;
const randomDelayMs = (char: string, nextChar = ''): number => {
if (char === '\n') {
if (nextChar === '\n') return (340 + Math.random() * 140) * SPEED;
return (200 + Math.random() * 90) * SPEED;
}
if (/[.!?…۔؟]/.test(char)) return (80 + Math.random() * 90) * SPEED;
if (/[,،;:]/.test(char)) return (40 + Math.random() * 40) * SPEED;
if (/\s/.test(char)) return (12 + Math.random() * 10) * SPEED;
return (15 + Math.random() * 15) * SPEED;
};
/** /**
* Simulated streaming typewriter with variable per-character delay. * Direct, continuous message stream hook.
* Stable across parent re-renders (onComplete/onProgress via refs). * Avoids slow character-by-character timer replay on complete responses
* while cleanly maintaining progress and completion callbacks.
*/ */
export function useStreamTypewriter({ export function useStreamTypewriter({
text, text,
@@ -32,13 +19,8 @@ export function useStreamTypewriter({
onProgress, onProgress,
onComplete, onComplete,
}: UseStreamTypewriterOptions) { }: UseStreamTypewriterOptions) {
const [displayedText, setDisplayedText] = useState(() => (enabled ? '' : text)); const [displayedText, setDisplayedText] = useState(text);
const [isStreaming, setIsStreaming] = useState(Boolean(enabled && text)); const [isStreaming, setIsStreaming] = useState(false);
const indexRef = useRef(0);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const textRef = useRef(text);
const enabledRef = useRef(enabled);
const completedForTextRef = useRef<string | null>(enabled ? null : text);
const onProgressRef = useRef(onProgress); const onProgressRef = useRef(onProgress);
const onCompleteRef = useRef(onComplete); const onCompleteRef = useRef(onComplete);
@@ -51,79 +33,18 @@ export function useStreamTypewriter({
}, [onComplete]); }, [onComplete]);
useEffect(() => { useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (!text) { if (!text) {
setDisplayedText(''); setDisplayedText('');
setIsStreaming(false); setIsStreaming(false);
indexRef.current = 0;
textRef.current = '';
enabledRef.current = enabled;
completedForTextRef.current = null;
return; return;
} }
if (!enabled) { setDisplayedText(text);
setDisplayedText(text); setIsStreaming(false);
setIsStreaming(false); onProgressRef.current?.(text);
textRef.current = text; onCompleteRef.current?.();
enabledRef.current = false;
completedForTextRef.current = text;
return;
}
const textChanged = textRef.current !== text;
const enabledChanged = enabledRef.current !== enabled;
textRef.current = text;
enabledRef.current = enabled;
if (!textChanged && !enabledChanged && completedForTextRef.current === text) {
setDisplayedText(text);
setIsStreaming(false);
return;
}
const shouldRestart =
textChanged || enabledChanged || completedForTextRef.current !== text;
if (shouldRestart) {
indexRef.current = 0;
completedForTextRef.current = null;
setDisplayedText('');
setIsStreaming(true);
const tick = () => {
const full = textRef.current;
if (indexRef.current >= full.length) {
setIsStreaming(false);
completedForTextRef.current = full;
onCompleteRef.current?.();
return;
}
const currentChar = full[indexRef.current] ?? '';
const nextChar = full[indexRef.current + 1] ?? '';
const nextIndex = indexRef.current + 1;
const nextText = full.slice(0, nextIndex);
indexRef.current = nextIndex;
setDisplayedText(nextText);
onProgressRef.current?.(nextText);
timeoutRef.current = setTimeout(tick, randomDelayMs(currentChar, nextChar));
};
timeoutRef.current = setTimeout(tick, randomDelayMs(text[0] ?? ' ', text[1] ?? ''));
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [text, enabled]); }, [text, enabled]);
return { displayedText, isStreaming }; return { displayedText, isStreaming };
} }
+4
View File
@@ -949,6 +949,10 @@
margin-inline-end: 0.15rem; margin-inline-end: 0.15rem;
} }
.bubble-ai ol:not(.list-none) {
list-style-type: decimal;
}
/* Ordered lists keep source numbers (۱. ۲. ۳.) — no CSS auto-renumber padding */ /* Ordered lists keep source numbers (۱. ۲. ۳.) — no CSS auto-renumber padding */
.bubble-ai ol.list-none { .bubble-ai ol.list-none {
padding-inline-start: 0; padding-inline-start: 0;