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
This commit is contained in:
ZhenYi 2026-04-25 09:52:58 +08:00
parent 57d0fc371e
commit 616c0c0e88
8 changed files with 151 additions and 18 deletions

View File

@ -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);

View File

@ -316,18 +316,46 @@ 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')}>
{functionCalls.length > 0 ? (
{/* Thinking phase — rendered as collapsible, muted style */}
{message.chunk_type === 'thinking' && !functionCalls.length && (
<div className="mb-1 rounded-md border px-3 py-2 text-sm italic" style={{ borderColor: 'var(--room-border)', color: 'var(--room-text-subtle)', background: 'var(--room-bg)' }}>
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--room-text-muted)' }}>Thinking</span>
<div className="mt-1">{displayContent}</div>
</div>
)}
{/* Tool call phase — rendered as compact badge */}
{message.chunk_type === 'tool_call' && (
<div className="mb-1 rounded-md border px-3 py-2 text-sm" style={{ borderColor: '#3b82f633', color: 'var(--room-text-secondary)', background: '#3b82f608' }}>
<span className="inline-flex items-center gap-1 text-[11px] font-medium" style={{ color: '#3b82f6' }}>
<span className="size-3 rounded-full bg-blue-500/30 border border-blue-500/60 inline-block" />
Tool Call
</span>
<div className="mt-1 font-mono text-xs" style={{ color: 'var(--room-text-subtle)' }}>{displayContent}</div>
</div>
)}
{/* Tool result phase — rendered as compact output */}
{message.chunk_type === 'tool_result' && (
<div className="mb-1 rounded-md border px-3 py-2 text-sm" style={{ borderColor: displayContent.includes('[Tool call failed') ? '#ef444433' : '#22c55e33', color: 'var(--room-text-secondary)', background: displayContent.includes('[Tool call failed') ? '#ef444408' : '#22c55e08' }}>
<span className="inline-flex items-center gap-1 text-[11px] font-medium" style={{ color: displayContent.includes('[Tool call failed') ? '#ef4444' : '#22c55e' }}>
<span className={cn('size-3 rounded-full inline-block', displayContent.includes('[Tool call failed') ? 'bg-red-500/30 border border-red-500/60' : 'bg-green-500/30 border border-green-500/60')} />
{displayContent.includes('[Tool call failed') ? 'Error' : 'Result'}
</span>
<div className="mt-1 font-mono text-xs whitespace-pre-wrap max-h-[120px] overflow-auto" style={{ color: 'var(--room-text-subtle)' }}>{displayContent}</div>
</div>
)}
{/* Normal answer or no chunk_type — default rendering */}
{!message.chunk_type && functionCalls.length > 0 ? (
functionCalls.map((call, index) => (
<div key={index} className="my-1 rounded-md border bg-white/5 p-2 max-w-xl" style={{ borderColor: 'var(--room-border)' }}>
<FunctionCallBadge functionCall={call} className="w-auto" />
</div>
))
) : (
) : !message.chunk_type ? (
<MessageContent
content={displayContent}
onMentionClick={handleMentionClick}
/>
)}
) : null}
{/* Streaming cursor */}
{isStreaming && <span className="discord-streaming-cursor" />}

View File

@ -95,6 +95,14 @@ 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);
// Reset initial load flag when switching rooms
useEffect(() => {
isInitialLoadRef.current = true;
wasNearBottomRef.current = true;
}, [roomId]);
const replyMap = useMemo(() => {
const map = new Map<string, MessageWithMeta>();
@ -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]);

View File

@ -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

View File

@ -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<Map<string, string>>(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<Map<string, ReturnType<typeof setTimeout>>>(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<ProjectRepositoryItem[]>([]);
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

View File

@ -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<RoomWsCallbacks>): 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;

View File

@ -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';

View File

@ -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 {