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:
parent
57d0fc371e
commit
616c0c0e88
@ -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);
|
||||
|
||||
@ -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" />}
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user