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:
parent
f5e3da35b0
commit
0939aa240b
@ -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,7 +408,21 @@ 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 */}
|
||||
{/* 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
|
||||
@ -346,16 +456,14 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Answer content — always visible */}
|
||||
{displayContent && (
|
||||
{message.content && (
|
||||
<MessageContent
|
||||
content={displayContent}
|
||||
content={message.content}
|
||||
onMentionClick={handleMentionClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Streaming cursor */}
|
||||
{isStreaming && <span className="discord-streaming-cursor" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Collapse gradient */}
|
||||
{isTextCollapsed && (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,20 +606,17 @@ 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) => {
|
||||
// Append chunk to ordered list — preserves think/answer/tool interleaving.
|
||||
setStreamingChunks((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);
|
||||
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
|
||||
streamingThinkingContentRef.current = new Map(next);
|
||||
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);
|
||||
@ -633,49 +632,10 @@ export function RoomProvider({
|
||||
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
|
||||
}
|
||||
},
|
||||
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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user