fix(frontend): prevent typing.stop on editor init, add typing display

- MessageInput: ignore empty text in handleEditorUpdate to avoid
  TipTap's onUpdate("") on init clearing the typing state
- DiscordChatPanel: show typing indicator when other users are typing
- room-context: wire onTypingStart/Stop into ws callbacks
This commit is contained in:
ZhenYi 2026-04-25 20:09:03 +08:00
parent 0a9dfef9b4
commit 73ba6329ea
6 changed files with 349 additions and 186 deletions

View File

@ -60,6 +60,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
roomAiConfigs, roomAiConfigs,
presence, presence,
typingUsers, typingUsers,
activeAiStream,
} = useRoom(); } = useRoom();
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@ -351,7 +352,26 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
onCreateThread={handleCreateThread} onCreateThread={handleCreateThread}
/> />
{/* Typing indicator — show who is typing */} {/* AI thinking / generating indicator */}
{activeAiStream && (
<div className="px-4 py-1 text-xs flex items-center gap-1.5" style={{ color: 'var(--room-text-subtle)' }}>
<span className="flex gap-0.5">
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 rounded-full"
style={{ background: 'var(--room-text-subtle)', animation: `typing-bounce 1.2s infinite ${i * 0.2}s` }}
/>
))}
</span>
<span>
<span style={{ color: 'var(--room-accent)', fontWeight: 500 }}>{activeAiStream.display_name}</span>
{' is thinking...'}
</span>
</div>
)}
{/* Human typing indicator — show who is typing */}
{(() => { {(() => {
const roomTyping = typingUsers?.[room.id] ?? {}; const roomTyping = typingUsers?.[room.id] ?? {};
const typingList = Object.entries(roomTyping); const typingList = Object.entries(roomTyping);

View File

@ -8,13 +8,11 @@
import type { MessageWithMeta } from '@/contexts'; import type { MessageWithMeta } from '@/contexts';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { parseFunctionCalls, type FunctionCall } from '@/lib/functionCallParser';
import { formatMessageTime } from '../shared/formatters'; import { formatMessageTime } from '../shared/formatters';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useUser, useRoom, useTheme } from '@/contexts'; import { useUser, useRoom, useTheme } from '@/contexts';
import { memo, useMemo, useState, useCallback, useRef } from 'react'; import { memo, useState, useCallback, useRef } from 'react';
import { ModelIcon } from '../icon-match'; import { ModelIcon } from '../icon-match';
import { FunctionCallBadge } from '../FunctionCallBadge';
import { MessageContent } from './MessageContent'; import { MessageContent } from './MessageContent';
import { ThreadIndicator } from '../RoomThreadPanel'; import { ThreadIndicator } from '../RoomThreadPanel';
import { getSenderDisplayName, getSenderUserUid, isUserSender } from '../sender'; import { getSenderDisplayName, getSenderUserUid, isUserSender } from '../sender';
@ -83,7 +81,7 @@ export const MessageBubble = memo(function MessageBubble({
const isEdited = !!message.edited_at; const isEdited = !!message.edited_at;
useTheme(); useTheme();
const { user } = useUser(); const { user } = useUser();
const { wsClient, streamingMessages, members, pins, pinMessage, unpinMessage } = useRoom(); const { wsClient, streamingMessages, streamingThinkingContent, members, pins, pinMessage, unpinMessage } = useRoom();
const avatarUrl = (() => { const avatarUrl = (() => {
if (message.sender_type === 'ai') return undefined; if (message.sender_type === 'ai') return undefined;
const member = members.find(m => m.user === message.sender_id); const member = members.find(m => m.user === message.sender_id);
@ -99,6 +97,12 @@ export const MessageBubble = memo(function MessageBubble({
? streamingMessages.get(message.id)! ? streamingMessages.get(message.id)!
: message.content; : 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 handleMentionClick = useCallback( const handleMentionClick = useCallback(
(type: string, id: string, label: string) => { (type: string, id: string, label: string) => {
if (!onOpenUserCard || type !== 'user') return; if (!onOpenUserCard || type !== 'user') return;
@ -134,14 +138,6 @@ export const MessageBubble = memo(function MessageBubble({
} }
}, [roomId, message.id, wsClient]); }, [roomId, message.id, wsClient]);
const functionCalls = useMemo<FunctionCall[]>(
() =>
message.content_type === 'text' || message.content_type === 'Text'
? parseFunctionCalls(displayContent)
: [],
[displayContent, message.content_type],
);
const textContent = displayContent; const textContent = displayContent;
const estimatedLines = textContent.split(/\r?\n/).reduce((total, line) => { const estimatedLines = textContent.split(/\r?\n/).reduce((total, line) => {
return total + Math.max(1, Math.ceil(line.trim().length / 90)); return total + Math.max(1, Math.ceil(line.trim().length / 90));
@ -316,46 +312,47 @@ export const MessageBubble = memo(function MessageBubble({
<div className="text-[15px] leading-[1.4] min-w-0" style={{ color: 'var(--room-text)' }}> <div className="text-[15px] leading-[1.4] min-w-0" style={{ color: 'var(--room-text)' }}>
{message.content_type === 'text' || message.content_type === 'Text' ? ( {message.content_type === 'text' || message.content_type === 'Text' ? (
<div className={cn('relative', isTextCollapsed && 'max-h-[4.5rem] overflow-hidden')}> <div className={cn('relative', isTextCollapsed && 'max-h-[4.5rem] overflow-hidden')}>
{/* Thinking phase — rendered as collapsible, muted style */} {/* Thinking/reasoning section — collapsible, DeepSeek-style */}
{message.chunk_type === 'thinking' && !functionCalls.length && ( {thinkingContent && (
<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)' }}> <div className="mb-2 rounded-lg border text-sm" style={{ borderColor: 'var(--room-border)', background: 'var(--room-bg)' }}>
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--room-text-muted)' }}>Thinking</span> <button
<div className="mt-1">{displayContent}</div> onClick={() => setThinkingExpanded(v => !v)}
</div> 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)' }}
{/* Tool call phase — rendered as compact badge */} >
{message.chunk_type === 'tool_call' && ( <svg
<div className="mb-1 rounded-md border px-3 py-2 text-sm" style={{ borderColor: '#3b82f633', color: 'var(--room-text-secondary)', background: '#3b82f608' }}> className={cn('size-3.5 transition-transform', thinkingExpanded && 'rotate-90')}
<span className="inline-flex items-center gap-1 text-[11px] font-medium" style={{ color: '#3b82f6' }}> viewBox="0 0 16 16"
<span className="size-3 rounded-full bg-blue-500/30 border border-blue-500/60 inline-block" /> fill="currentColor"
Tool Call >
<path d="M6 4l4 4-4 4" />
</svg>
<span className="text-xs font-semibold uppercase tracking-wider opacity-70">
Thinking
</span> </span>
<div className="mt-1 font-mono text-xs" style={{ color: 'var(--room-text-subtle)' }}>{displayContent}</div> {thinkingContent && (
</div> <span className="text-[11px] opacity-50">
)} · {thinkingContent.split(/\s+/).filter(Boolean).length} tokens
{/* 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> </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> )}
<svg className="ml-auto size-3.5 opacity-40" viewBox="0 0 16 16" fill="currentColor">
<path d={thinkingExpanded ? 'M4 10l4-4 4 4' : 'M4 6l4 4 4-4'} />
</svg>
</button>
{thinkingExpanded && (
<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)' }}>
{thinkingContent}
</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> </div>
)) )}
) : !message.chunk_type ? ( {/* Answer content — always visible */}
{displayContent && (
<MessageContent <MessageContent
content={displayContent} content={displayContent}
onMentionClick={handleMentionClick} onMentionClick={handleMentionClick}
/> />
) : null} )}
{/* Streaming cursor */} {/* Streaming cursor */}
{isStreaming && <span className="discord-streaming-cursor" />} {isStreaming && <span className="discord-streaming-cursor" />}

View File

@ -5,11 +5,11 @@
* Supports @mentions, file uploads, emoji picker, and rich message AST. * Supports @mentions, file uploads, emoji picker, and rich message AST.
*/ */
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'; import {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef} from 'react';
import { IMEditor } from './editor/IMEditor'; import type {IMEditorHandle} from './editor/IMEditor';
import { useRoom } from '@/contexts'; import {IMEditor} from './editor/IMEditor';
import type { MessageAST, EditorNode } from './editor/types'; import {useRoom} from '@/contexts';
import type { IMEditorHandle } from './editor/IMEditor'; import type {EditorNode, MessageAST} from './editor/types';
export interface MessageInputProps { export interface MessageInputProps {
roomName: string; roomName: string;
@ -28,10 +28,15 @@ export interface MessageInputHandle {
// Slash commands available in the editor // Slash commands available in the editor
const SLASH_COMMANDS = [ const SLASH_COMMANDS = [
{ id: 'ai', label: '/ai', description: 'Ask AI a question', type: 'command' as const }, {id: 'ai', label: '/ai', description: 'Ask AI a question', type: 'command' as const},
{ id: 'remind', label: '/remind', description: 'Set a reminder (e.g. /remind 10m Check CI)', type: 'command' as const }, {
{ id: 'poll', label: '/poll', description: 'Create a poll (e.g. /poll "Question?" A B C)', type: 'command' as const }, id: 'remind',
{ id: 'code-review', label: '/code-review', description: 'Request AI code review', type: 'command' as const }, label: '/remind',
description: 'Set a reminder (e.g. /remind 10m Check CI)',
type: 'command' as const
},
{id: 'poll', label: '/poll', description: 'Create a poll (e.g. /poll "Question?" A B C)', type: 'command' as const},
{id: 'code-review', label: '/code-review', description: 'Request AI code review', type: 'command' as const},
]; ];
// Special mention items — @here (online), @channel (all members) // Special mention items — @here (online), @channel (all members)
@ -68,10 +73,10 @@ function serializeNode(node: EditorNode): string {
} }
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput( export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
{ roomName, onSend, replyingTo, onCancelReply }, {roomName, onSend, replyingTo, onCancelReply},
ref, ref,
) { ) {
const { members, activeRoomId, roomAiConfigs } = useRoom(); const {members, activeRoomId, roomAiConfigs, wsClient} = useRoom();
// Ref passed to the inner IMEditor // Ref passed to the inner IMEditor
const innerEditorRef = useRef<IMEditorHandle | null>(null); const innerEditorRef = useRef<IMEditorHandle | null>(null);
@ -86,6 +91,46 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [], getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
}), []); }), []);
// Typing indicator: debounce start/stop
const typingStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sendTypingStart = useCallback(() => {
if (!wsClient || !activeRoomId) return;
if (typingStopTimerRef.current) {
clearTimeout(typingStopTimerRef.current);
typingStopTimerRef.current = null;
}
wsClient.sendTyping(activeRoomId, 'start');
}, [wsClient, activeRoomId]);
const sendTypingStop = useCallback(() => {
if (!wsClient || !activeRoomId) return;
if (typingStopTimerRef.current) {
clearTimeout(typingStopTimerRef.current);
typingStopTimerRef.current = null;
}
wsClient.sendTyping(activeRoomId, 'stop');
}, [wsClient, activeRoomId]);
const handleEditorUpdate = useCallback((text: string) => {
if (!text.trim()) {
// Ignore empty updates (e.g. TipTap fires onUpdate("") on init).
// Only stop typing on explicit clear or send.
return;
}
sendTypingStart();
// Auto-stop after 1.5s of inactivity
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
typingStopTimerRef.current = setTimeout(sendTypingStop, 1500);
}, [sendTypingStart, sendTypingStop]);
// Stop typing on send or clear
useEffect(() => {
return () => {
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
};
}, []);
// Transform room data into MentionItems — memoized to prevent IMEditor re-creation // Transform room data into MentionItems — memoized to prevent IMEditor re-creation
const mentionItems = useMemo(() => ({ const mentionItems = useMemo(() => ({
users: members.map((m) => ({ users: members.map((m) => ({
@ -110,13 +155,14 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin; const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
const res = await fetch(`${baseUrl}/rooms/${activeRoomId}/upload`, { method: 'POST', body: formData }); const res = await fetch(`${baseUrl}/rooms/${activeRoomId}/upload`, {method: 'POST', body: formData});
if (!res.ok) throw new Error('Upload failed'); if (!res.ok) throw new Error('Upload failed');
return res.json(); return res.json();
}; };
// onSend: serialize AST to backend-parseable format // onSend: serialize AST to backend-parseable format
const handleSend = (_text: string, ast: MessageAST) => { const handleSend = (_text: string, ast: MessageAST) => {
sendTypingStop();
const serialized = serializeMessageAst(ast); const serialized = serializeMessageAst(ast);
onSend(serialized); onSend(serialized);
}; };
@ -130,6 +176,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
mentionItems={mentionItems} mentionItems={mentionItems}
onUploadFile={handleUploadFile} onUploadFile={handleUploadFile}
placeholder={`Message #${roomName}`} placeholder={`Message #${roomName}`}
onUpdate={handleEditorUpdate}
/> />
); );
}); });

View File

@ -72,6 +72,8 @@ export type MessageWithMeta = RoomMessageResponse & {
attachment_ids?: string[]; attachment_ids?: string[];
/** AI stream chunk type: "thinking", "tool_call", "tool_result", or undefined for normal text */ /** AI stream chunk type: "thinking", "tool_call", "tool_result", or undefined for normal text */
chunk_type?: string; chunk_type?: string;
/** Accumulated thinking/reasoning content from AI stream (collapsible) */
thinking_content?: string;
}; };
export type RoomWithCategory = RoomResponse & { export type RoomWithCategory = RoomResponse & {
@ -156,6 +158,10 @@ interface RoomContextValue {
updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise<void>; updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise<void>;
deleteRoom: (roomId: string) => Promise<void>; deleteRoom: (roomId: string) => Promise<void>;
streamingMessages: Map<string, string>; streamingMessages: Map<string, string>;
/** Streaming thinking/reasoning content keyed by message_id */
streamingThinkingContent: Map<string, string>;
/** Active AI stream info for typing indicator */
activeAiStream: { message_id: string; display_name: string } | null;
/** Project repositories for @repository: mention suggestions */ /** Project repositories for @repository: mention suggestions */
projectRepos: ProjectRepositoryItem[]; projectRepos: ProjectRepositoryItem[];
@ -432,12 +438,19 @@ export function RoomProvider({
// Typing users map: roomId -> Map<userId, { username, avatar_url, timeoutId }> // Typing users map: roomId -> Map<userId, { username, avatar_url, timeoutId }>
const [typingUsers, setTypingUsers] = useState<Record<string, Record<string, { username: string; avatar_url?: string; timeoutId?: ReturnType<typeof setTimeout> }>>>({}); const [typingUsers, setTypingUsers] = useState<Record<string, Record<string, { username: string; avatar_url?: string; timeoutId?: ReturnType<typeof setTimeout> }>>>({});
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map()); const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
const [streamingThinkingContent, setStreamingThinkingContent] = useState<Map<string, 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 // Streaming timeout: if no chunk received for 60s, force-end the stream
// to prevent UI hanging forever when done=true is never delivered. // to prevent UI hanging forever when done=true is never delivered.
const streamingTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map()); 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());
const clearStreamingTimer = useCallback((msgId: string) => { const clearStreamingTimer = useCallback((msgId: string) => {
const timer = streamingTimersRef.current.get(msgId); const timer = streamingTimersRef.current.get(msgId);
if (timer) { if (timer) {
@ -450,10 +463,16 @@ export function RoomProvider({
clearStreamingTimer(msgId); clearStreamingTimer(msgId);
const timer = setTimeout(() => { const timer = setTimeout(() => {
// Force-end: mark message as not-streaming and keep whatever content we have // Force-end: mark message as not-streaming and keep whatever content we have
setActiveAiStream((prev) => prev?.message_id === msgId ? null : prev);
setStreamingContent((prev) => { setStreamingContent((prev) => {
prev.delete(msgId); prev.delete(msgId);
return new Map(prev); return new Map(prev);
}); });
setStreamingThinkingContent((prev) => {
prev.delete(msgId);
return new Map(prev);
});
streamingThinkingContentRef.current.delete(msgId);
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === msgId && m.is_streaming m.id === msgId && m.is_streaming
@ -544,37 +563,88 @@ export function RoomProvider({
} }
}, },
onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string; chunk_type?: string }) => { onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string; chunk_type?: string }) => {
const isToolCall = chunk.chunk_type === 'tool_call' || chunk.chunk_type === 'tool_result';
if (chunk.done) { if (chunk.done) {
// Clear the timeout timer since stream completed normally // Clear the timeout timer since stream completed normally
clearStreamingTimer(chunk.message_id); clearStreamingTimer(chunk.message_id);
// When done: clear streaming content, set is_streaming=false, and // Set activeAiStream to null since streaming is done
// update seq so the subsequent RoomMessage event deduplicates correctly. setActiveAiStream(null);
// Clear streaming content maps
setStreamingContent((prev) => { setStreamingContent((prev) => {
prev.delete(chunk.message_id); prev.delete(chunk.message_id);
return new Map(prev); 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
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) => {
m.id === chunk.message_id if (m.id !== chunk.message_id) return m;
? { ...m, content: chunk.content, display_content: chunk.content, is_streaming: false, chunk_type: chunk.chunk_type } // Get thinking_content from the accumulator before it was cleared
: m, 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,
chunk_type: chunk.chunk_type,
};
}),
); );
} else { } else {
// Reset the timeout timer on each chunk — stream is still alive // Reset the timeout timer on each chunk — stream is still alive
startStreamingTimer(chunk.message_id); startStreamingTimer(chunk.message_id);
// Single atomic update: accumulate in streamingContent AND update message. // Update activeAiStream for typing indicator (skip tool call / result)
// Backend sends CUMULATIVE content (text_accumulated.clone()), not delta. if (!isToolCall && chunk.display_name) {
// Use deduplication to only add the new delta portion. setActiveAiStream({ message_id: chunk.message_id, display_name: chunk.display_name });
}
if (chunk.chunk_type === 'thinking') {
// Accumulate thinking content separately
setStreamingThinkingContent((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);
// Sync ref for done handler access
streamingThinkingContentRef.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);
if (idx !== -1) return msgs;
const newMsg: MessageWithMeta = {
id: chunk.message_id,
room: chunk.room_id,
seq: 0,
sender_type: 'ai',
display_name: chunk.display_name,
content: '',
display_content: '',
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) => { setStreamingContent((prev) => {
const next = new Map(prev); const next = new Map(prev);
const prevContent = next.get(chunk.message_id) ?? ''; const prevContent = next.get(chunk.message_id) ?? '';
// Only append the delta (the part of chunk.content that is NEW).
// This prevents double-accumulation since backend already sends cumulative text.
const newContent = const newContent =
prevContent === '' || !chunk.content.startsWith(prevContent) prevContent === '' || !chunk.content.startsWith(prevContent)
? chunk.content // First chunk or content diverged — use as-is ? chunk.content
: prevContent + chunk.content.slice(prevContent.length); // Append delta : prevContent + chunk.content.slice(prevContent.length);
next.set(chunk.message_id, newContent); next.set(chunk.message_id, newContent);
setMessages((msgs) => { setMessages((msgs) => {
const idx = msgs.findIndex((m) => m.id === chunk.message_id); const idx = msgs.findIndex((m) => m.id === chunk.message_id);
@ -604,6 +674,8 @@ export function RoomProvider({
return next; return next;
}); });
} }
// tool_call / tool_result: skip content update entirely — don't pollute display
}
}, },
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => { onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
if (payload.room_id !== activeRoomIdRef.current) return; if (payload.room_id !== activeRoomIdRef.current) return;
@ -696,7 +768,7 @@ export function RoomProvider({
}, },
onTypingStart: (payload) => { onTypingStart: (payload) => {
if (payload.room_id !== activeRoomIdRef.current) return; if (payload.room_id !== activeRoomIdRef.current) return;
if (payload.user_id === user?.uid) return; // Don't show self if (payload.user_id === user?.uid) return;
setTypingUsers((prev) => { setTypingUsers((prev) => {
const roomMap = prev[payload.room_id] ?? {}; const roomMap = prev[payload.room_id] ?? {};
// Clear existing timeout for this user // Clear existing timeout for this user
@ -709,13 +781,14 @@ export function RoomProvider({
return { ...p, [payload.room_id]: rm }; return { ...p, [payload.room_id]: rm };
}); });
}, 4000); }, 4000);
return { const next = {
...prev, ...prev,
[payload.room_id]: { [payload.room_id]: {
...roomMap, ...roomMap,
[payload.user_id]: { username: payload.username, avatar_url: payload.avatar_url, timeoutId }, [payload.user_id]: { username: payload.username, avatar_url: payload.avatar_url, timeoutId },
}, },
}; };
return next;
}); });
}, },
onTypingStop: (payload) => { onTypingStop: (payload) => {
@ -1379,6 +1452,8 @@ export function RoomProvider({
updateRoom, updateRoom,
deleteRoom, deleteRoom,
streamingMessages: streamingContent, streamingMessages: streamingContent,
streamingThinkingContent,
activeAiStream,
projectRepos, projectRepos,
reposLoading, reposLoading,
roomAiConfigs, roomAiConfigs,
@ -1433,6 +1508,8 @@ export function RoomProvider({
updateRoom, updateRoom,
deleteRoom, deleteRoom,
streamingContent, streamingContent,
streamingThinkingContent,
activeAiStream,
projectRepos, projectRepos,
reposLoading, reposLoading,
roomAiConfigs, roomAiConfigs,

View File

@ -971,11 +971,11 @@ export class RoomWsClient {
return url; return url;
} }
/** Send a typing_start / typing_stop event directly via WebSocket push (no response needed). */ /** Send a typing_start / typing_stop event via WebSocket request. */
sendTyping(roomId: string, action: 'start' | 'stop'): void { sendTyping(roomId: string, action: 'start' | 'stop'): void {
if (this.ws && this.status === 'open') { if (this.ws && this.status === 'open') {
const event = { type: 'event', event: `typing_${action}`, room_id: roomId }; const wsAction = action === 'start' ? 'typing.start' as WsAction : 'typing.stop' as WsAction;
this.ws.send(JSON.stringify(event)); this.requestWs<void>(wsAction, { room_id: roomId, typing: action }).catch(() => {});
} }
} }
@ -1051,6 +1051,25 @@ export class RoomWsClient {
status: ((event.data as { status?: string })?.status ?? 'offline') as 'online' | 'away' | 'dnd' | 'offline', status: ((event.data as { status?: string })?.status ?? 'offline') as 'online' | 'away' | 'dnd' | 'offline',
}); });
break; break;
case 'room.typing':
case 'room_typing':
{
const data = event.data as { user_id?: string; username?: string; avatar_url?: string; action?: string } | undefined;
if (data?.action === 'start') {
this.callbacks.onTypingStart?.({
room_id: event.room_id ?? '',
user_id: data.user_id ?? '',
username: data.username ?? '',
avatar_url: data.avatar_url,
});
} else if (data?.action === 'stop') {
this.callbacks.onTypingStop?.({
room_id: event.room_id ?? '',
user_id: data.user_id ?? '',
});
}
}
break;
case 'typing.start': case 'typing.start':
case 'typing_start': case 'typing_start':
this.callbacks.onTypingStart?.({ this.callbacks.onTypingStart?.({

View File

@ -50,7 +50,9 @@ export type WsAction =
| 'room.unsubscribe' | 'room.unsubscribe'
| 'project.subscribe' | 'project.subscribe'
| 'project.unsubscribe' | 'project.unsubscribe'
| 'reaction.list_batch'; | 'reaction.list_batch'
| 'typing.start'
| 'typing.stop';
export interface WsRequestParams { export interface WsRequestParams {
project_name?: string; project_name?: string;
@ -92,6 +94,7 @@ export interface WsRequestParams {
query?: string; query?: string;
message_ids?: string[]; message_ids?: string[];
attachment_ids?: string[]; attachment_ids?: string[];
typing?: string;
} }
export interface WsResponse { export interface WsResponse {