diff --git a/src/components/chat/MessageBubble.tsx b/src/components/chat/MessageBubble.tsx index 5c38366..003d7f8 100644 --- a/src/components/chat/MessageBubble.tsx +++ b/src/components/chat/MessageBubble.tsx @@ -20,7 +20,7 @@ type MessageBubbleProps = { type ContentBlock = | { type: 'p'; 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[][] }; const stripImageMarkdown = (text: string): string => @@ -132,16 +132,16 @@ const isTableLine = (line: string): boolean => line.includes('|') && line.trim().split('|').filter(Boolean).length >= 2; /** 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 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); 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 => @@ -201,6 +201,7 @@ const parseBlocks = (text: string): ContentBlock[] => { const ordered = isOrderedItem(line); const items: string[] = []; const markers: string[] = []; + const delimiters: string[] = []; while (i < lines.length) { const current = lines[i] ?? ''; if (!current.trim()) break; @@ -210,6 +211,7 @@ const parseBlocks = (text: string): ContentBlock[] => { if (!parsed) break; // Keep the model's own numbering (۱/۲/۳…) — do not renumber with CSS 1,2,3. markers.push(parsed.marker); + delimiters.push(parsed.delimiter); items.push(parsed.text); } else { items.push(stripListMarker(current)); @@ -217,7 +219,9 @@ const parseBlocks = (text: string): ContentBlock[] => { i += 1; } 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; } @@ -351,8 +355,9 @@ export const MessageBubble = ({ > {block.ordered ? ( <> - - {block.markers?.[itemIndex] ?? String(itemIndex + 1)}. + + {block.markers?.[itemIndex] ?? String(itemIndex + 1)} + {block.delimiters?.[itemIndex] ?? '.'} {renderInline(item, isActivelyTyping)} diff --git a/src/hooks/useStreamTypewriter.ts b/src/hooks/useStreamTypewriter.ts index 10e0629..9ac3387 100644 --- a/src/hooks/useStreamTypewriter.ts +++ b/src/hooks/useStreamTypewriter.ts @@ -8,23 +8,10 @@ type UseStreamTypewriterOptions = { 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. - * Stable across parent re-renders (onComplete/onProgress via refs). + * Direct, continuous message stream hook. + * Avoids slow character-by-character timer replay on complete responses + * while cleanly maintaining progress and completion callbacks. */ export function useStreamTypewriter({ text, @@ -32,13 +19,8 @@ export function useStreamTypewriter({ onProgress, onComplete, }: UseStreamTypewriterOptions) { - const [displayedText, setDisplayedText] = useState(() => (enabled ? '' : text)); - const [isStreaming, setIsStreaming] = useState(Boolean(enabled && text)); - const indexRef = useRef(0); - const timeoutRef = useRef | null>(null); - const textRef = useRef(text); - const enabledRef = useRef(enabled); - const completedForTextRef = useRef(enabled ? null : text); + const [displayedText, setDisplayedText] = useState(text); + const [isStreaming, setIsStreaming] = useState(false); const onProgressRef = useRef(onProgress); const onCompleteRef = useRef(onComplete); @@ -51,79 +33,18 @@ export function useStreamTypewriter({ }, [onComplete]); useEffect(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - if (!text) { setDisplayedText(''); setIsStreaming(false); - indexRef.current = 0; - textRef.current = ''; - enabledRef.current = enabled; - completedForTextRef.current = null; return; } - if (!enabled) { - setDisplayedText(text); - setIsStreaming(false); - textRef.current = text; - 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; - } - }; + setDisplayedText(text); + setIsStreaming(false); + onProgressRef.current?.(text); + onCompleteRef.current?.(); }, [text, enabled]); return { displayedText, isStreaming }; } + diff --git a/src/styles/globals.css b/src/styles/globals.css index 86b42c9..f027671 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -949,6 +949,10 @@ 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 */ .bubble-ai ol.list-none { padding-inline-start: 0;