From 616c0c0e88d157a8f655143765bbb95ee0261830 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 25 Apr 2026 09:52:58 +0800 Subject: [PATCH] fix(room): scroll-to-bottom logic and AI sender display name - Remove duplicate smooth scroll effect from DiscordChatPanel; handle all scroll logic in MessageList instead - MessageList: track isInitialLoadRef to instant-jump to bottom on first load (no animation), and only auto-scroll for new messages when user is already near the bottom - sender.ts: getSenderDisplayName rejects UUID values and falls back to 'AI' for AI messages; getSenderModelId uses display_name --- src/components/room/DiscordChatPanel.tsx | 4 +- src/components/room/message/MessageBubble.tsx | 34 +++++++++++-- src/components/room/message/MessageList.tsx | 34 +++++++++++-- src/components/room/sender.ts | 23 ++++++--- src/contexts/room-context.tsx | 48 ++++++++++++++++++- src/lib/room-ws-client.ts | 15 +++++- src/lib/room.ts | 1 + src/lib/ws-protocol.ts | 10 ++++ 8 files changed, 151 insertions(+), 18 deletions(-) diff --git a/src/components/room/DiscordChatPanel.tsx b/src/components/room/DiscordChatPanel.tsx index d4c2c95..5e0f693 100644 --- a/src/components/room/DiscordChatPanel.tsx +++ b/src/components/room/DiscordChatPanel.tsx @@ -195,9 +195,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha [room.id, updateRoom], ); - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages.length]); + // Scroll handling is done entirely by MessageList useEffect(() => { setReplyingTo(null); diff --git a/src/components/room/message/MessageBubble.tsx b/src/components/room/message/MessageBubble.tsx index 08b4c4d..1fbb878 100644 --- a/src/components/room/message/MessageBubble.tsx +++ b/src/components/room/message/MessageBubble.tsx @@ -316,18 +316,46 @@ export const MessageBubble = memo(function MessageBubble({
{message.content_type === 'text' || message.content_type === 'Text' ? (
- {functionCalls.length > 0 ? ( + {/* Thinking phase — rendered as collapsible, muted style */} + {message.chunk_type === 'thinking' && !functionCalls.length && ( +
+ Thinking +
{displayContent}
+
+ )} + {/* Tool call phase — rendered as compact badge */} + {message.chunk_type === 'tool_call' && ( +
+ + + Tool Call + +
{displayContent}
+
+ )} + {/* Tool result phase — rendered as compact output */} + {message.chunk_type === 'tool_result' && ( +
+ + + {displayContent.includes('[Tool call failed') ? 'Error' : 'Result'} + +
{displayContent}
+
+ )} + {/* Normal answer or no chunk_type — default rendering */} + {!message.chunk_type && functionCalls.length > 0 ? ( functionCalls.map((call, index) => (
)) - ) : ( + ) : !message.chunk_type ? ( - )} + ) : null} {/* Streaming cursor */} {isStreaming && } diff --git a/src/components/room/message/MessageList.tsx b/src/components/room/message/MessageList.tsx index 25b6add..824cf42 100644 --- a/src/components/room/message/MessageList.tsx +++ b/src/components/room/message/MessageList.tsx @@ -95,6 +95,14 @@ export const MessageList = memo(function MessageList({ const scrollTimeoutRef = useRef | null>(null); const isRestoringScrollRef = useRef(false); const firstVisibleMessageIdRef = useRef(null); + const isInitialLoadRef = useRef(true); + const wasNearBottomRef = useRef(true); + + // Reset initial load flag when switching rooms + useEffect(() => { + isInitialLoadRef.current = true; + wasNearBottomRef.current = true; + }, [roomId]); const replyMap = useMemo(() => { const map = new Map(); @@ -146,8 +154,11 @@ export const MessageList = memo(function MessageList({ }, [messages, replyMap]); const scrollToBottom = useCallback((smooth = true) => { - messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' }); - }, [messagesEndRef]); + const container = scrollContainerRef.current; + if (container) { + container.scrollTo({ top: container.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); + } + }, []); const handleScroll = useCallback(() => { const container = scrollContainerRef.current; @@ -155,6 +166,7 @@ export const MessageList = memo(function MessageList({ const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const nearBottom = distanceFromBottom < 100; + wasNearBottomRef.current = nearBottom; requestAnimationFrame(() => { setShowScrollToBottom(!nearBottom); @@ -184,8 +196,24 @@ export const MessageList = memo(function MessageList({ 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 < 100) { + if (distanceFromBottom < 150) { + wasNearBottomRef.current = true; requestAnimationFrame(() => scrollToBottom(false)); } }, [messages.length, scrollToBottom]); diff --git a/src/components/room/sender.ts b/src/components/room/sender.ts index 573e108..e98b9d1 100644 --- a/src/components/room/sender.ts +++ b/src/components/room/sender.ts @@ -5,25 +5,36 @@ export function getSenderUserUid(message: MessageWithMeta): string | undefined { return message.sender_id ?? undefined; } -/** Returns the model ID for AI messages */ +/** Returns the model ID for AI messages. + * For AI messages, display_name is the model name; sender_id should be null. + * Returns undefined if sender_id looks like a UUID (which would be a user UID, not a model ID). */ export function getSenderModelId(message: MessageWithMeta): string | undefined { - if (message.sender_type === 'ai' && message.sender_id) { - return message.sender_id; + if (message.sender_type === 'ai') { + // Use display_name for model identification since sender_id is null for proper AI messages + if (message.display_name && !looksLikeUuid(message.display_name)) return message.display_name; + return undefined; } return undefined; } -/** Display name for a message sender */ +/** Display name for a message sender. + * For AI messages, prefers display_name (model name), falls back to 'AI'. + * Never returns a raw UUID for AI messages. */ export function getSenderDisplayName(message: MessageWithMeta): string { if (message.sender_type === 'ai') { - if (message.display_name) return message.display_name; + if (message.display_name && !looksLikeUuid(message.display_name)) return message.display_name; return 'AI'; } - if (message.display_name) return message.display_name; if (message.sender_type === 'system') return 'System'; + if (message.sender_type === 'tool') return 'Tool'; + if (message.display_name) return message.display_name; return message.sender_type; } +function looksLikeUuid(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); +} + /** Avatar URL from a MessageWithMeta. * Callers should pass members to resolve the avatar. * This helper returns undefined for now — avatar resolution is done in components diff --git a/src/contexts/room-context.tsx b/src/contexts/room-context.tsx index ee4dc0a..e5f8be4 100644 --- a/src/contexts/room-context.tsx +++ b/src/contexts/room-context.tsx @@ -70,6 +70,8 @@ export type MessageWithMeta = RoomMessageResponse & { reactions?: ReactionGroup[]; /** Attachment IDs for files uploaded with this message */ attachment_ids?: string[]; + /** AI stream chunk type: "thinking", "tool_call", "tool_result", or undefined for normal text */ + chunk_type?: string; }; export type RoomWithCategory = RoomResponse & { @@ -432,6 +434,38 @@ export function RoomProvider({ const [streamingContent, setStreamingContent] = useState>(new Map()); + // 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>>(new Map()); + + const clearStreamingTimer = useCallback((msgId: string) => { + const timer = streamingTimersRef.current.get(msgId); + if (timer) { + clearTimeout(timer); + streamingTimersRef.current.delete(msgId); + } + }, []); + + const startStreamingTimer = useCallback((msgId: string) => { + clearStreamingTimer(msgId); + const timer = setTimeout(() => { + // Force-end: mark message as not-streaming and keep whatever content we have + setStreamingContent((prev) => { + prev.delete(msgId); + return new Map(prev); + }); + setMessages((prev) => + prev.map((m) => + m.id === msgId && m.is_streaming + ? { ...m, is_streaming: false, content: m.content || '[Stream timed out — no completion signal received]' } + : m, + ), + ); + streamingTimersRef.current.delete(msgId); + }, 60000); + streamingTimersRef.current.set(msgId, timer); + }, []); + // Project repos for @repository: mention suggestions const [projectRepos, setProjectRepos] = useState([]); const [reposLoading, setReposLoading] = useState(false); @@ -509,8 +543,10 @@ export function RoomProvider({ ]); } }, - onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string }) => { + onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string; chunk_type?: string }) => { if (chunk.done) { + // Clear the timeout timer since stream completed normally + clearStreamingTimer(chunk.message_id); // When done: clear streaming content, set is_streaming=false, and // update seq so the subsequent RoomMessage event deduplicates correctly. setStreamingContent((prev) => { @@ -520,11 +556,13 @@ export function RoomProvider({ setMessages((prev) => prev.map((m) => m.id === chunk.message_id - ? { ...m, content: chunk.content, display_content: chunk.content, is_streaming: false } + ? { ...m, content: chunk.content, display_content: chunk.content, is_streaming: false, chunk_type: chunk.chunk_type } : m, ), ); } else { + // Reset the timeout timer on each chunk — stream is still alive + startStreamingTimer(chunk.message_id); // Single atomic update: accumulate in streamingContent AND update message. // Backend sends CUMULATIVE content (text_accumulated.clone()), not delta. // Use deduplication to only add the new delta portion. @@ -559,6 +597,7 @@ export function RoomProvider({ content_type: 'text', send_at: new Date().toISOString(), is_streaming: true, + chunk_type: chunk.chunk_type, }; return [...msgs, newMsg]; }); @@ -715,6 +754,11 @@ export function RoomProvider({ return () => { client.disconnect(); // Intentional disconnect on unmount — no reconnect wsClientRef.current = null; + // Clear all streaming timeout timers + for (const timer of streamingTimersRef.current.values()) { + clearTimeout(timer); + } + streamingTimersRef.current.clear(); }; }, []); // ← empty deps: create once on mount diff --git a/src/lib/room-ws-client.ts b/src/lib/room-ws-client.ts index 6130eb8..b4d1279 100644 --- a/src/lib/room-ws-client.ts +++ b/src/lib/room-ws-client.ts @@ -29,6 +29,7 @@ import type { UserInfo, RoomReactionUpdatedPayload, UserPresencePayload, + NotificationCreatedPayload, } from './ws-protocol'; export type { @@ -53,10 +54,10 @@ export type { UserInfo, RoomReactionUpdatedPayload, UserPresencePayload, + NotificationCreatedPayload, }; export interface WsTokenResponse { - token: string; expires_in_seconds: number; } @@ -84,6 +85,8 @@ export interface RoomWsCallbacks { onError?: (error: Error) => void; /** Called each time the client sends a heartbeat ping */ onHeartbeat?: () => void; + /** Called when a new notification is pushed from the server via WebSocket */ + onNotification?: (payload: import('./ws-protocol').NotificationCreatedPayload) => void; } export class RoomWsClient { @@ -133,6 +136,11 @@ export class RoomWsClient { this.wsToken = token; } + /** Update callbacks (e.g. to register onNotification after construction). */ + updateCallbacks(callbacks: Partial): void { + Object.assign(this.callbacks, callbacks); + } + getWsToken(): string | null { return this.wsToken; } @@ -1059,6 +1067,11 @@ export class RoomWsClient { user_id: (event.data as { user_id?: string })?.user_id ?? '', }); break; + case 'notification_created': + this.callbacks.onNotification?.( + event.data as import('./ws-protocol').NotificationCreatedPayload, + ); + break; default: // Unknown event type - ignore silently break; diff --git a/src/lib/room.ts b/src/lib/room.ts index aa23b5b..5846eb9 100644 --- a/src/lib/room.ts +++ b/src/lib/room.ts @@ -37,6 +37,7 @@ export type AiStreamChunkPayload = { content: string; done: boolean; error?: string | null; + chunk_type?: string | null; }; export type RoomWsStatus = 'idle' | 'connecting' | 'open' | 'closing' | 'closed' | 'error'; diff --git a/src/lib/ws-protocol.ts b/src/lib/ws-protocol.ts index 5b69535..c968115 100644 --- a/src/lib/ws-protocol.ts +++ b/src/lib/ws-protocol.ts @@ -170,8 +170,16 @@ export type WsEventPayload = | { type: 'user_presence'; data: UserPresencePayload } | { type: 'typing_start'; data: TypingStartPayload } | { type: 'typing_stop'; data: TypingStopPayload } + | { type: 'notification_created'; data: NotificationCreatedPayload } | { type: string; data: unknown }; // catch-all for unknown events +/** Payload for real-time notification push via WebSocket. */ +export interface NotificationCreatedPayload { + notification: NotificationData; + /** URL to navigate to for this notification (e.g. /project/x/issues/42) */ + deep_link_url?: string; +} + export interface RoomMessagePayload { id: string; room_id: string; @@ -205,6 +213,8 @@ export interface AiStreamChunkPayload { error?: string; /** Human-readable AI model name for display (e.g. "Claude 3.5 Sonnet"). */ display_name?: string; + /** What kind of content: "thinking", "answer", "tool_call", "tool_result". */ + chunk_type?: string; } export interface RoomResponse {