From 0939aa240baea335e5e406635f50ac9ac392c03a Mon Sep 17 00:00:00 2001
From: ZhenYi <434836402@qq.com>
Date: Sun, 26 Apr 2026 13:10:51 +0800
Subject: [PATCH] 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
---
src/components/room/message/MessageBubble.tsx | 210 +++++++++++++-----
src/components/room/message/MessageList.tsx | 64 +++---
src/contexts/room-context.tsx | 160 +++++--------
3 files changed, 246 insertions(+), 188 deletions(-)
diff --git a/src/components/room/message/MessageBubble.tsx b/src/components/room/message/MessageBubble.tsx
index 3b023c7..5d4f450 100644
--- a/src/components/room/message/MessageBubble.tsx
+++ b/src/components/room/message/MessageBubble.tsx
@@ -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' ? (
+