fix(frontend): ordered chunk rendering + initial scroll-to-bottom

- OrderedStreamChunks renders think/answer interleaved per arrival order
- parseSavedChunks parses stored __chunks__ JSON on page refresh
- Tool call chunks hidden from frontend display
- Fix streaming join('') instead of join('\n') to avoid per-token newlines
- Fix MessageList scroll-to-bottom using virtualizer.scrollToIndex
- Remove unused streamingContent/streamingThinkingContent state
- Add retryable error patterns for HTTP connection issues
This commit is contained in:
ZhenYi 2026-04-26 13:10:51 +08:00
parent f5e3da35b0
commit 0939aa240b
3 changed files with 246 additions and 188 deletions

View File

@ -19,6 +19,107 @@ import { getSenderDisplayName, getSenderUserUid, isUserSender } from '../sender'
import { MessageReactions } from './MessageReactions';
import { ReactionPicker } from './ReactionPicker';
/** Parse thinking text from stored thinking_content (may be __chunks__ JSON or plain text). */
function parseThinkingText(raw: string): string {
if (!raw) return '';
try {
const parsed = JSON.parse(raw) as { __chunks__?: Array<{ type: string; content: string }> };
if (parsed.__chunks__) {
return parsed.__chunks__
.filter((c) => c.type === 'thinking')
.map((c) => c.content)
.join('');
}
} catch {
// Not JSON — plain text, use as-is
}
return raw;
}
/** Parse ordered chunks from stored thinking_content JSON. Returns null if not in __chunks__ format. */
function parseSavedChunks(raw: string | null | undefined): Array<{ type: string; content: string }> | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { __chunks__?: Array<{ type: string; content: string }> };
if (parsed.__chunks__) return parsed.__chunks__;
} catch {
// Not JSON — legacy plain text
}
return null;
}
/** Render ordered stream chunks: consecutive thinking tokens are merged into one collapsible block, answer tokens rendered inline. tool_call is hidden. */
function OrderedStreamChunks({
chunks,
onMentionClick,
}: {
chunks: Array<{ type: string; content: string }>;
onMentionClick?: (type: string, id: string, label: string) => void;
}) {
// Group consecutive same-type chunks (tool_call hidden)
const groups: Array<{ type: 'thinking' | 'answer'; content: string }> = [];
for (const chunk of chunks) {
if (chunk.type === 'tool_call') continue;
const cType = chunk.type === 'thinking' ? 'thinking' : 'answer';
const last = groups[groups.length - 1];
if (last && last.type === cType) {
last.content += chunk.content;
} else {
groups.push({ type: cType, content: chunk.content });
}
}
return (
<>
{groups.map((group, i) =>
group.type === 'thinking' ? (
<ThinkingBlock key={i} content={group.content} />
) : (
<MessageContent key={i} content={group.content} onMentionClick={onMentionClick} />
),
)}
{/* Streaming cursor */}
<span className="discord-streaming-cursor" />
</>
);
}
/** Collapsible thinking block with auto-expand. */
function ThinkingBlock({ content }: { content: string }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="mb-2 last:mb-0 rounded-lg border text-sm" style={{ borderColor: 'var(--room-border)', background: 'var(--room-bg)' }}>
<button
onClick={() => setExpanded(v => !v)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:opacity-80"
style={{ color: 'var(--room-text-secondary)' }}
>
<svg
className={cn('size-3.5 transition-transform', expanded && 'rotate-90')}
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M6 4l4 4-4 4" />
</svg>
<span className="text-xs font-semibold uppercase tracking-wider opacity-70">
Thinking
</span>
<span className="text-[11px] opacity-50">
· {content.split(/\s+/).filter(Boolean).length} tokens
</span>
<svg className="ml-auto size-3.5 opacity-40" viewBox="0 0 16 16" fill="currentColor">
<path d={expanded ? 'M4 10l4-4 4 4' : 'M4 6l4 4 4-4'} />
</svg>
</button>
{expanded && (
<div className="border-t px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap" style={{ borderColor: 'var(--room-border)', color: 'var(--room-text-subtle)' }}>
{content}
</div>
)}
</div>
);
}
// Sender colors — AI Studio clean palette
const SENDER_COLORS: Record<string, string> = {
system: '#9ca3af',
@ -81,7 +182,7 @@ export const MessageBubble = memo(function MessageBubble({
const isEdited = !!message.edited_at;
useTheme();
const { user } = useUser();
const { wsClient, streamingMessages, streamingThinkingContent, members, pins, pinMessage, unpinMessage } = useRoom();
const { wsClient, streamingChunks, members, pins, pinMessage, unpinMessage } = useRoom();
const avatarUrl = (() => {
if (message.sender_type === 'ai') return undefined;
const member = members.find(m => m.user === message.sender_id);
@ -93,15 +194,10 @@ export const MessageBubble = memo(function MessageBubble({
const isPending = message.isOptimistic === true || message.id.startsWith('temp-') || message.id.startsWith('optimistic-');
const isPinned = pins.some(p => p.message === message.id);
const displayContent = isStreaming && streamingMessages?.has(message.id)
? streamingMessages.get(message.id)!
: message.content;
// Thinking/reasoning content: from streamingThinkingContent while live, or stored thinking_content on message
const thinkingContent = isStreaming && streamingThinkingContent?.has(message.id)
? streamingThinkingContent.get(message.id)!
: (message.thinking_content ?? '');
const [thinkingExpanded, setThinkingExpanded] = useState(false);
const thinkingContent = isStreaming && streamingChunks?.has(message.id)
? streamingChunks.get(message.id)!.filter(c => c.type === 'thinking').map(c => c.content).join('')
: parseThinkingText(message.thinking_content ?? '');
const handleMentionClick = useCallback(
(type: string, id: string, label: string) => {
@ -138,7 +234,7 @@ export const MessageBubble = memo(function MessageBubble({
}
}, [roomId, message.id, wsClient]);
const textContent = displayContent;
const textContent = message.content;
const estimatedLines = textContent.split(/\r?\n/).reduce((total, line) => {
return total + Math.max(1, Math.ceil(line.trim().length / 90));
}, 0);
@ -312,51 +408,63 @@ export const MessageBubble = memo(function MessageBubble({
<div className="text-[15px] leading-[1.4] min-w-0" style={{ color: 'var(--room-text)' }}>
{message.content_type === 'text' || message.content_type === 'Text' ? (
<div className={cn('relative', isTextCollapsed && 'max-h-[4.5rem] overflow-hidden')}>
{/* Thinking/reasoning section — collapsible, DeepSeek-style */}
{thinkingContent && (
<div className="mb-2 rounded-lg border text-sm" style={{ borderColor: 'var(--room-border)', background: 'var(--room-bg)' }}>
<button
onClick={() => setThinkingExpanded(v => !v)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:opacity-80"
style={{ color: 'var(--room-text-secondary)' }}
>
<svg
className={cn('size-3.5 transition-transform', thinkingExpanded && 'rotate-90')}
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M6 4l4 4-4 4" />
</svg>
<span className="text-xs font-semibold uppercase tracking-wider opacity-70">
Thinking
</span>
{thinkingContent && (
<span className="text-[11px] opacity-50">
· {thinkingContent.split(/\s+/).filter(Boolean).length} tokens
</span>
)}
<svg className="ml-auto size-3.5 opacity-40" viewBox="0 0 16 16" fill="currentColor">
<path d={thinkingExpanded ? 'M4 10l4-4 4 4' : 'M4 6l4 4 4-4'} />
</svg>
</button>
{thinkingExpanded && (
<div className="border-t px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap" style={{ borderColor: 'var(--room-border)', color: 'var(--room-text-subtle)' }}>
{thinkingContent}
</div>
)}
</div>
)}
{/* Answer content — always visible */}
{displayContent && (
<MessageContent
content={displayContent}
{/* Streaming: ordered chunks — think/answer interleaved, tool_call hidden */}
{isStreaming && streamingChunks?.has(message.id) ? (
<OrderedStreamChunks
chunks={streamingChunks.get(message.id)!}
onMentionClick={handleMentionClick}
/>
) : parseSavedChunks(message.thinking_content) ? (
/* Saved ordered chunks — render in original order */
<OrderedStreamChunks
chunks={parseSavedChunks(message.thinking_content)!}
onMentionClick={handleMentionClick}
/>
) : (
/* Legacy: aggregated thinking at top, content at bottom */
<>
{thinkingContent && (
<div className="mb-2 rounded-lg border text-sm" style={{ borderColor: 'var(--room-border)', background: 'var(--room-bg)' }}>
<button
onClick={() => setThinkingExpanded(v => !v)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:opacity-80"
style={{ color: 'var(--room-text-secondary)' }}
>
<svg
className={cn('size-3.5 transition-transform', thinkingExpanded && 'rotate-90')}
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M6 4l4 4-4 4" />
</svg>
<span className="text-xs font-semibold uppercase tracking-wider opacity-70">
Thinking
</span>
{thinkingContent && (
<span className="text-[11px] opacity-50">
· {thinkingContent.split(/\s+/).filter(Boolean).length} tokens
</span>
)}
<svg className="ml-auto size-3.5 opacity-40" viewBox="0 0 16 16" fill="currentColor">
<path d={thinkingExpanded ? 'M4 10l4-4 4 4' : 'M4 6l4 4 4-4'} />
</svg>
</button>
{thinkingExpanded && (
<div className="border-t px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap" style={{ borderColor: 'var(--room-border)', color: 'var(--room-text-subtle)' }}>
{thinkingContent}
</div>
)}
</div>
)}
{message.content && (
<MessageContent
content={message.content}
onMentionClick={handleMentionClick}
/>
)}
</>
)}
{/* Streaming cursor */}
{isStreaming && <span className="discord-streaming-cursor" />}
{/* Collapse gradient */}
{isTextCollapsed && (
<div

View File

@ -95,13 +95,13 @@ export const MessageList = memo(function MessageList({
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isRestoringScrollRef = useRef(false);
const firstVisibleMessageIdRef = useRef<string | null>(null);
const isInitialLoadRef = useRef(true);
const wasNearBottomRef = useRef(true);
const didInitialLayoutRef = useRef(false);
// Reset initial load flag when switching rooms
// Reset scroll flags when switching rooms
useEffect(() => {
isInitialLoadRef.current = true;
wasNearBottomRef.current = true;
didInitialLayoutRef.current = false;
}, [roomId]);
const replyMap = useMemo(() => {
@ -153,13 +153,6 @@ export const MessageList = memo(function MessageList({
return result;
}, [messages, replyMap]);
const scrollToBottom = useCallback((smooth = true) => {
const container = scrollContainerRef.current;
if (container) {
container.scrollTo({ top: container.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
}
}, []);
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
@ -192,32 +185,6 @@ export const MessageList = memo(function MessageList({
};
}, [handleScroll]);
useEffect(() => {
if (messages.length === 0) return;
const container = scrollContainerRef.current;
if (!container) return;
// On initial load, jump to bottom instantly (no animation)
if (isInitialLoadRef.current) {
isInitialLoadRef.current = false;
wasNearBottomRef.current = true;
// Use requestAnimationFrame to wait for virtualizer to layout
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollToBottom(false);
});
});
return;
}
// For new messages: auto-scroll only if user was near bottom
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceFromBottom < 150) {
wasNearBottomRef.current = true;
requestAnimationFrame(() => scrollToBottom(false));
}
}, [messages.length, scrollToBottom]);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollContainerRef.current,
@ -231,6 +198,31 @@ export const MessageList = memo(function MessageList({
gap: 0,
});
const scrollToBottom = useCallback((smooth = true) => {
if (rows.length === 0) return;
virtualizer.scrollToIndex(rows.length - 1, { align: 'end', smooth });
}, [virtualizer, rows.length]);
// Ensure scroll-to-bottom fires after virtualizer measures all rows
useEffect(() => {
if (messages.length === 0) return;
if (didInitialLayoutRef.current) return;
const container = scrollContainerRef.current;
if (!container) return;
// Only fire when virtualizer has a meaningful total size
if (virtualizer.getTotalSize() < 10) return;
didInitialLayoutRef.current = true;
wasNearBottomRef.current = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollToBottom(false);
});
});
}, [virtualizer.getTotalSize(), messages.length, scrollToBottom]);
const virtualItems = virtualizer.getVirtualItems();
// IntersectionObserver for load more

View File

@ -98,6 +98,7 @@ function wsMessageToUiMessage(wsMsg: RoomMessagePayload): MessageWithMeta {
display_content: wsMsg.content,
is_streaming: false,
reactions: wsMsg.reactions,
thinking_content: wsMsg.thinking_content,
};
}
@ -157,9 +158,8 @@ interface RoomContextValue {
createRoom: (name: string, isPublic: boolean, categoryId?: string) => Promise<RoomResponse>;
updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise<void>;
deleteRoom: (roomId: string) => Promise<void>;
streamingMessages: Map<string, string>;
/** Streaming thinking/reasoning content keyed by message_id */
streamingThinkingContent: Map<string, string>;
/** Streaming chunks in arrival order per message_id — preserves think/answer interleaving */
streamingChunks: Map<string, Array<{ type: string; content: string }>>;
/** Active AI stream info for typing indicator */
activeAiStream: { message_id: string; display_name: string } | null;
@ -440,16 +440,15 @@ export function RoomProvider({
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
const [streamingThinkingContent, setStreamingThinkingContent] = useState<Map<string, string>>(new Map());
const [streamingChunks, setStreamingChunks] = useState<Map<string, Array<{ type: string; content: string }>>>(new Map());
const [activeAiStream, setActiveAiStream] = useState<{ message_id: string; display_name: string } | null>(null);
// Streaming timeout: if no chunk received for 60s, force-end the stream
// to prevent UI hanging forever when done=true is never delivered.
const streamingTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
// Ref to latest streamingThinkingContent so done handler can read it (setState is async)
const streamingThinkingContentRef = useRef<Map<string, string>>(new Map());
// Ref to latest streamingChunks so done handler can read accumulated thinking (setState is async)
const streamingChunksRef = useRef<Map<string, Array<{ type: string; content: string }>>>(new Map());
const clearStreamingTimer = useCallback((msgId: string) => {
const timer = streamingTimersRef.current.get(msgId);
@ -464,15 +463,11 @@ export function RoomProvider({
const timer = setTimeout(() => {
// Force-end: mark message as not-streaming and keep whatever content we have
setActiveAiStream((prev) => prev?.message_id === msgId ? null : prev);
setStreamingContent((prev) => {
setStreamingChunks((prev) => {
prev.delete(msgId);
return new Map(prev);
});
setStreamingThinkingContent((prev) => {
prev.delete(msgId);
return new Map(prev);
});
streamingThinkingContentRef.current.delete(msgId);
streamingChunksRef.current.delete(msgId);
setMessages((prev) =>
prev.map((m) =>
m.id === msgId && m.is_streaming
@ -514,18 +509,20 @@ export function RoomProvider({
const existingIdx = prev.findIndex((m) => m.id === payload.id);
if (existingIdx !== -1) {
// Message already exists (e.g. created by streaming chunk) —
// merge server-side fields (display_name, reactions) that the
// merge server-side fields (display_name, reactions, thinking_content) that the
// chunk didn't have.
const existing = prev[existingIdx];
const needsUpdate =
(!existing.display_name && payload.display_name) ||
(payload.reactions !== undefined && existing.reactions === undefined);
(payload.reactions !== undefined && existing.reactions === undefined) ||
(payload.thinking_content && !existing.thinking_content);
if (needsUpdate) {
const updated = [...prev];
updated[existingIdx] = {
...existing,
display_name: payload.display_name ?? existing.display_name,
reactions: payload.reactions ?? existing.reactions,
thinking_content: payload.thinking_content ?? existing.thinking_content,
};
return updated;
}
@ -571,27 +568,32 @@ export function RoomProvider({
clearStreamingTimer(chunk.message_id);
// Set activeAiStream to null since streaming is done
setActiveAiStream(null);
// Clear streaming content maps
setStreamingContent((prev) => {
// Get the ordered chunk list for this message.
// Build final content/thinking_content from ordered chunks for persistence.
const orderedChunks = streamingChunksRef.current.get(chunk.message_id) ?? [];
// For thinking_content: concatenate all thinking chunks in order
const thinkingText = orderedChunks
.filter(c => c.type === 'thinking')
.map(c => c.content)
.join('');
// Clear streaming state
setStreamingChunks((prev) => {
prev.delete(chunk.message_id);
return new Map(prev);
});
setStreamingThinkingContent((prev) => {
prev.delete(chunk.message_id);
return new Map(prev);
});
// Finalize message: keep thinking_content from accumulator, set content from done chunk
// Finalize message with ordered content
setMessages((prev) =>
prev.map((m) => {
if (m.id !== chunk.message_id) return m;
// Get thinking_content from the accumulator before it was cleared
const tc = streamingThinkingContentRef.current.get(chunk.message_id);
return {
...m,
content: chunk.content,
display_content: chunk.content,
is_streaming: false,
thinking_content: tc ?? m.thinking_content,
thinking_content: thinkingText || m.thinking_content,
chunk_type: chunk.chunk_type,
};
}),
@ -604,78 +606,36 @@ export function RoomProvider({
setActiveAiStream({ message_id: chunk.message_id, display_name: chunk.display_name });
}
if (chunk.chunk_type === 'thinking') {
// Accumulate thinking content separately
setStreamingThinkingContent((prev) => {
const next = new Map(prev);
const prevContent = next.get(chunk.message_id) ?? '';
const newContent =
prevContent === '' || !chunk.content.startsWith(prevContent)
? chunk.content
: prevContent + chunk.content.slice(prevContent.length);
next.set(chunk.message_id, newContent);
// Sync ref for done handler access
streamingThinkingContentRef.current = new Map(next);
return next;
});
// Ensure message entry exists (with minimal content to show streaming state)
setMessages((msgs) => {
const idx = msgs.findIndex((m) => m.id === chunk.message_id);
if (idx !== -1) return msgs;
const newMsg: MessageWithMeta = {
id: chunk.message_id,
room: chunk.room_id,
seq: 0,
sender_type: 'ai',
display_name: chunk.display_name,
content: '',
display_content: '',
content_type: 'text',
send_at: new Date().toISOString(),
is_streaming: true,
chunk_type: 'thinking',
};
return [...msgs, newMsg];
});
} else if (chunk.chunk_type === 'answer') {
// Accumulate answer content (existing behavior)
setStreamingContent((prev) => {
const next = new Map(prev);
const prevContent = next.get(chunk.message_id) ?? '';
const newContent =
prevContent === '' || !chunk.content.startsWith(prevContent)
? chunk.content
: prevContent + chunk.content.slice(prevContent.length);
next.set(chunk.message_id, newContent);
setMessages((msgs) => {
const idx = msgs.findIndex((m) => m.id === chunk.message_id);
if (idx !== -1) {
const m = msgs[idx];
if (m.content === newContent && m.is_streaming === true) return msgs;
const updated = [...msgs];
updated[idx] = { ...m, content: newContent, display_content: newContent };
return updated;
}
if (!newContent) return msgs;
const newMsg: MessageWithMeta = {
id: chunk.message_id,
room: chunk.room_id,
seq: 0,
sender_type: 'ai',
display_name: chunk.display_name,
content: newContent,
display_content: newContent,
content_type: 'text',
send_at: new Date().toISOString(),
is_streaming: true,
chunk_type: chunk.chunk_type,
};
return [...msgs, newMsg];
});
return next;
});
}
// tool_call / tool_result: skip content update entirely — don't pollute display
// Append chunk to ordered list — preserves think/answer/tool interleaving.
setStreamingChunks((prev) => {
const next = new Map(prev);
const existing = next.get(chunk.message_id) ?? [];
const newChunks = [...existing, { type: chunk.chunk_type ?? 'answer', content: chunk.content }];
next.set(chunk.message_id, newChunks);
// Sync ref for done handler access
streamingChunksRef.current = new Map(next);
return next;
});
// Ensure message entry exists (with minimal content to show streaming state)
setMessages((msgs) => {
const idx = msgs.findIndex((m) => m.id === chunk.message_id);
if (idx !== -1) return msgs;
const newMsg: MessageWithMeta = {
id: chunk.message_id,
room: chunk.room_id,
seq: 0,
sender_type: 'ai',
display_name: chunk.display_name,
content: '',
display_content: '',
content_type: 'text',
send_at: new Date().toISOString(),
is_streaming: true,
chunk_type: chunk.chunk_type,
};
return [...msgs, newMsg];
});
}
},
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
@ -1478,8 +1438,7 @@ export function RoomProvider({
createRoom,
updateRoom,
deleteRoom,
streamingMessages: streamingContent,
streamingThinkingContent,
streamingChunks,
activeAiStream,
projectRepos,
reposLoading,
@ -1534,8 +1493,7 @@ export function RoomProvider({
createRoom,
updateRoom,
deleteRoom,
streamingContent,
streamingThinkingContent,
streamingChunks,
activeAiStream,
projectRepos,
reposLoading,