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,
presence,
typingUsers,
activeAiStream,
} = useRoom();
const messagesEndRef = useRef<HTMLDivElement>(null);
@ -351,7 +352,26 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
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 typingList = Object.entries(roomTyping);

View File

@ -8,13 +8,11 @@
import type { MessageWithMeta } from '@/contexts';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { parseFunctionCalls, type FunctionCall } from '@/lib/functionCallParser';
import { formatMessageTime } from '../shared/formatters';
import { cn } from '@/lib/utils';
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 { FunctionCallBadge } from '../FunctionCallBadge';
import { MessageContent } from './MessageContent';
import { ThreadIndicator } from '../RoomThreadPanel';
import { getSenderDisplayName, getSenderUserUid, isUserSender } from '../sender';
@ -83,7 +81,7 @@ export const MessageBubble = memo(function MessageBubble({
const isEdited = !!message.edited_at;
useTheme();
const { user } = useUser();
const { wsClient, streamingMessages, members, pins, pinMessage, unpinMessage } = useRoom();
const { wsClient, streamingMessages, streamingThinkingContent, members, pins, pinMessage, unpinMessage } = useRoom();
const avatarUrl = (() => {
if (message.sender_type === 'ai') return undefined;
const member = members.find(m => m.user === message.sender_id);
@ -99,6 +97,12 @@ export const MessageBubble = memo(function MessageBubble({
? 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 handleMentionClick = useCallback(
(type: string, id: string, label: string) => {
if (!onOpenUserCard || type !== 'user') return;
@ -134,14 +138,6 @@ export const MessageBubble = memo(function MessageBubble({
}
}, [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 estimatedLines = textContent.split(/\r?\n/).reduce((total, line) => {
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)' }}>
{message.content_type === 'text' || message.content_type === 'Text' ? (
<div className={cn('relative', isTextCollapsed && 'max-h-[4.5rem] overflow-hidden')}>
{/* 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>
{/* Thinking/reasoning section — collapsible, DeepSeek-style */}
{thinkingContent && (
<div className="mb-2 rounded-lg border text-sm" style={{ borderColor: 'var(--room-border)', background: 'var(--room-bg)' }}>
<button
onClick={() => setThinkingExpanded(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', thinkingExpanded && '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>
{thinkingContent && (
<span className="text-[11px] opacity-50">
· {thinkingContent.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={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>
)}
{/* 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 ? (
{/* Answer content — always visible */}
{displayContent && (
<MessageContent
content={displayContent}
onMentionClick={handleMentionClick}
/>
) : null}
content={displayContent}
onMentionClick={handleMentionClick}
/>
)}
{/* Streaming cursor */}
{isStreaming && <span className="discord-streaming-cursor" />}

View File

@ -5,131 +5,178 @@
* Supports @mentions, file uploads, emoji picker, and rich message AST.
*/
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
import { IMEditor } from './editor/IMEditor';
import { useRoom } from '@/contexts';
import type { MessageAST, EditorNode } from './editor/types';
import type { IMEditorHandle } from './editor/IMEditor';
import {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef} from 'react';
import type {IMEditorHandle} from './editor/IMEditor';
import {IMEditor} from './editor/IMEditor';
import {useRoom} from '@/contexts';
import type {EditorNode, MessageAST} from './editor/types';
export interface MessageInputProps {
roomName: string;
onSend: (content: string, attachmentIds?: string[]) => void;
replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void;
roomName: string;
onSend: (content: string, attachmentIds?: string[]) => void;
replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void;
}
export interface MessageInputHandle {
focus: () => void;
clearContent: () => void;
getContent: () => string;
insertMention: (type: string, id: string, label: string) => void;
getAttachmentIds: () => string[];
focus: () => void;
clearContent: () => void;
getContent: () => string;
insertMention: (type: string, id: string, label: string) => void;
getAttachmentIds: () => string[];
}
// Slash commands available in the editor
const SLASH_COMMANDS = [
{ 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: 'code-review', label: '/code-review', description: 'Request AI code review', 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: 'code-review', label: '/code-review', description: 'Request AI code review', type: 'command' as const},
];
// Special mention items — @here (online), @channel (all members)
const SPECIAL_MENTIONS = [
{
id: '__here__',
label: 'here',
description: 'Notify online members',
type: 'special_here' as const,
},
{
id: '__channel__',
label: 'channel',
description: 'Notify all members',
type: 'special_channel' as const,
},
{
id: '__here__',
label: 'here',
description: 'Notify online members',
type: 'special_here' as const,
},
{
id: '__channel__',
label: 'channel',
description: 'Notify all members',
type: 'special_channel' as const,
},
];
/** Serialize tiptap AST to backend-parseable string format. */
function serializeMessageAst(ast: MessageAST): string {
return ast.content.map(serializeNode).join('\n');
return ast.content.map(serializeNode).join('\n');
}
function serializeNode(node: EditorNode): string {
if (node.type === 'text') return node.text;
if (node.type === 'mention') return `@[${node.attrs.type}:${node.attrs.id}:${node.attrs.label}]`;
if (node.type === 'hardBreak') return '\n';
if (node.type === 'file') return ''; // files are sent separately via attachmentIds
if (node.type === 'emoji') return `[emoji:${node.attrs.name}]`;
// Recurse into container nodes (paragraph, bulletList, etc.)
const children = (node as any).content as EditorNode[] | undefined;
if (children) return children.map(serializeNode).join('');
return '';
if (node.type === 'text') return node.text;
if (node.type === 'mention') return `@[${node.attrs.type}:${node.attrs.id}:${node.attrs.label}]`;
if (node.type === 'hardBreak') return '\n';
if (node.type === 'file') return ''; // files are sent separately via attachmentIds
if (node.type === 'emoji') return `[emoji:${node.attrs.name}]`;
// Recurse into container nodes (paragraph, bulletList, etc.)
const children = (node as any).content as EditorNode[] | undefined;
if (children) return children.map(serializeNode).join('');
return '';
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
{ roomName, onSend, replyingTo, onCancelReply },
ref,
{roomName, onSend, replyingTo, onCancelReply},
ref,
) {
const { members, activeRoomId, roomAiConfigs } = useRoom();
const {members, activeRoomId, roomAiConfigs, wsClient} = useRoom();
// Ref passed to the inner IMEditor
const innerEditorRef = useRef<IMEditorHandle | null>(null);
// Ref passed to the inner IMEditor
const innerEditorRef = useRef<IMEditorHandle | null>(null);
// Expose a subset of IMEditorHandle (plus getAttachmentIds) as MessageInputHandle
useImperativeHandle(ref, () => ({
focus: () => innerEditorRef.current?.focus(),
clearContent: () => innerEditorRef.current?.clearContent(),
getContent: () => innerEditorRef.current?.getContent() ?? '',
insertMention: (type: string, id: string, label: string) =>
innerEditorRef.current?.insertMention(type, id, label),
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
}), []);
// Expose a subset of IMEditorHandle (plus getAttachmentIds) as MessageInputHandle
useImperativeHandle(ref, () => ({
focus: () => innerEditorRef.current?.focus(),
clearContent: () => innerEditorRef.current?.clearContent(),
getContent: () => innerEditorRef.current?.getContent() ?? '',
insertMention: (type: string, id: string, label: string) =>
innerEditorRef.current?.insertMention(type, id, label),
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
}), []);
// Transform room data into MentionItems — memoized to prevent IMEditor re-creation
const mentionItems = useMemo(() => ({
users: members.map((m) => ({
id: m.user,
label: m.user_info?.username ?? m.user,
type: 'user' as const,
avatar: m.user_info?.avatar_url ?? undefined,
})),
channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
ai: roomAiConfigs.map((cfg) => ({
id: cfg.model,
label: cfg.modelName ?? cfg.model,
type: 'ai' as const,
})),
commands: SLASH_COMMANDS,
specialMentions: SPECIAL_MENTIONS,
}), [members, roomAiConfigs]);
// Typing indicator: debounce start/stop
const typingStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// File upload handler — POST to /rooms/{room_id}/upload
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
if (!activeRoomId) throw new Error('No active room');
const formData = new FormData();
formData.append('file', file);
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
const res = await fetch(`${baseUrl}/rooms/${activeRoomId}/upload`, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload failed');
return res.json();
};
const sendTypingStart = useCallback(() => {
if (!wsClient || !activeRoomId) return;
if (typingStopTimerRef.current) {
clearTimeout(typingStopTimerRef.current);
typingStopTimerRef.current = null;
}
wsClient.sendTyping(activeRoomId, 'start');
}, [wsClient, activeRoomId]);
// onSend: serialize AST to backend-parseable format
const handleSend = (_text: string, ast: MessageAST) => {
const serialized = serializeMessageAst(ast);
onSend(serialized);
};
const sendTypingStop = useCallback(() => {
if (!wsClient || !activeRoomId) return;
if (typingStopTimerRef.current) {
clearTimeout(typingStopTimerRef.current);
typingStopTimerRef.current = null;
}
wsClient.sendTyping(activeRoomId, 'stop');
}, [wsClient, activeRoomId]);
return (
<IMEditor
ref={innerEditorRef}
replyingTo={replyingTo}
onCancelReply={onCancelReply}
onSend={handleSend}
mentionItems={mentionItems}
onUploadFile={handleUploadFile}
placeholder={`Message #${roomName}`}
/>
);
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
const mentionItems = useMemo(() => ({
users: members.map((m) => ({
id: m.user,
label: m.user_info?.username ?? m.user,
type: 'user' as const,
avatar: m.user_info?.avatar_url ?? undefined,
})),
channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
ai: roomAiConfigs.map((cfg) => ({
id: cfg.model,
label: cfg.modelName ?? cfg.model,
type: 'ai' as const,
})),
commands: SLASH_COMMANDS,
specialMentions: SPECIAL_MENTIONS,
}), [members, roomAiConfigs]);
// File upload handler — POST to /rooms/{room_id}/upload
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
if (!activeRoomId) throw new Error('No active room');
const formData = new FormData();
formData.append('file', file);
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
const res = await fetch(`${baseUrl}/rooms/${activeRoomId}/upload`, {method: 'POST', body: formData});
if (!res.ok) throw new Error('Upload failed');
return res.json();
};
// onSend: serialize AST to backend-parseable format
const handleSend = (_text: string, ast: MessageAST) => {
sendTypingStop();
const serialized = serializeMessageAst(ast);
onSend(serialized);
};
return (
<IMEditor
ref={innerEditorRef}
replyingTo={replyingTo}
onCancelReply={onCancelReply}
onSend={handleSend}
mentionItems={mentionItems}
onUploadFile={handleUploadFile}
placeholder={`Message #${roomName}`}
onUpdate={handleEditorUpdate}
/>
);
});

View File

@ -72,6 +72,8 @@ export type MessageWithMeta = RoomMessageResponse & {
attachment_ids?: string[];
/** AI stream chunk type: "thinking", "tool_call", "tool_result", or undefined for normal text */
chunk_type?: string;
/** Accumulated thinking/reasoning content from AI stream (collapsible) */
thinking_content?: string;
};
export type RoomWithCategory = RoomResponse & {
@ -156,6 +158,10 @@ interface RoomContextValue {
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>;
/** Active AI stream info for typing indicator */
activeAiStream: { message_id: string; display_name: string } | null;
/** Project repositories for @repository: mention suggestions */
projectRepos: ProjectRepositoryItem[];
@ -432,12 +438,19 @@ export function RoomProvider({
// 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 [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
// 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());
const clearStreamingTimer = useCallback((msgId: string) => {
const timer = streamingTimersRef.current.get(msgId);
if (timer) {
@ -450,10 +463,16 @@ export function RoomProvider({
clearStreamingTimer(msgId);
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) => {
prev.delete(msgId);
return new Map(prev);
});
setStreamingThinkingContent((prev) => {
prev.delete(msgId);
return new Map(prev);
});
streamingThinkingContentRef.current.delete(msgId);
setMessages((prev) =>
prev.map((m) =>
m.id === msgId && m.is_streaming
@ -544,65 +563,118 @@ export function RoomProvider({
}
},
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) {
// 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.
// Set activeAiStream to null since streaming is done
setActiveAiStream(null);
// Clear streaming content maps
setStreamingContent((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
setMessages((prev) =>
prev.map((m) =>
m.id === chunk.message_id
? { ...m, content: chunk.content, display_content: chunk.content, is_streaming: false, chunk_type: chunk.chunk_type }
: m,
),
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,
chunk_type: chunk.chunk_type,
};
}),
);
} 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.
setStreamingContent((prev) => {
const next = new Map(prev);
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 =
prevContent === '' || !chunk.content.startsWith(prevContent)
? chunk.content // First chunk or content diverged — use as-is
: prevContent + chunk.content.slice(prevContent.length); // Append delta
next.set(chunk.message_id, newContent);
// Update activeAiStream for typing indicator (skip tool call / result)
if (!isToolCall && chunk.display_name) {
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) {
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;
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: newContent,
display_content: newContent,
content: '',
display_content: '',
content_type: 'text',
send_at: new Date().toISOString(),
is_streaming: true,
chunk_type: chunk.chunk_type,
chunk_type: 'thinking',
};
return [...msgs, newMsg];
});
return next;
});
} 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) => {
@ -696,7 +768,7 @@ export function RoomProvider({
},
onTypingStart: (payload) => {
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) => {
const roomMap = prev[payload.room_id] ?? {};
// Clear existing timeout for this user
@ -709,13 +781,14 @@ export function RoomProvider({
return { ...p, [payload.room_id]: rm };
});
}, 4000);
return {
const next = {
...prev,
[payload.room_id]: {
...roomMap,
[payload.user_id]: { username: payload.username, avatar_url: payload.avatar_url, timeoutId },
},
};
return next;
});
},
onTypingStop: (payload) => {
@ -1379,6 +1452,8 @@ export function RoomProvider({
updateRoom,
deleteRoom,
streamingMessages: streamingContent,
streamingThinkingContent,
activeAiStream,
projectRepos,
reposLoading,
roomAiConfigs,
@ -1433,6 +1508,8 @@ export function RoomProvider({
updateRoom,
deleteRoom,
streamingContent,
streamingThinkingContent,
activeAiStream,
projectRepos,
reposLoading,
roomAiConfigs,

View File

@ -971,11 +971,11 @@ export class RoomWsClient {
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 {
if (this.ws && this.status === 'open') {
const event = { type: 'event', event: `typing_${action}`, room_id: roomId };
this.ws.send(JSON.stringify(event));
const wsAction = action === 'start' ? 'typing.start' as WsAction : 'typing.stop' as WsAction;
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',
});
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':
this.callbacks.onTypingStart?.({

View File

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