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)}
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> </div>
)} )}
{/* Tool call phase — rendered as compact badge */} {/* Answer content — always visible */}
{message.chunk_type === 'tool_call' && ( {displayContent && (
<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 <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,131 +5,178 @@
* 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;
onSend: (content: string, attachmentIds?: string[]) => void; onSend: (content: string, attachmentIds?: string[]) => void;
replyingTo?: { id: string; display_name?: string; content: string } | null; replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void; onCancelReply?: () => void;
} }
export interface MessageInputHandle { export interface MessageInputHandle {
focus: () => void; focus: () => void;
clearContent: () => void; clearContent: () => void;
getContent: () => string; getContent: () => string;
insertMention: (type: string, id: string, label: string) => void; insertMention: (type: string, id: string, label: string) => void;
getAttachmentIds: () => string[]; getAttachmentIds: () => string[];
} }
// 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)
const SPECIAL_MENTIONS = [ const SPECIAL_MENTIONS = [
{ {
id: '__here__', id: '__here__',
label: 'here', label: 'here',
description: 'Notify online members', description: 'Notify online members',
type: 'special_here' as const, type: 'special_here' as const,
}, },
{ {
id: '__channel__', id: '__channel__',
label: 'channel', label: 'channel',
description: 'Notify all members', description: 'Notify all members',
type: 'special_channel' as const, type: 'special_channel' as const,
}, },
]; ];
/** Serialize tiptap AST to backend-parseable string format. */ /** Serialize tiptap AST to backend-parseable string format. */
function serializeMessageAst(ast: MessageAST): string { function serializeMessageAst(ast: MessageAST): string {
return ast.content.map(serializeNode).join('\n'); return ast.content.map(serializeNode).join('\n');
} }
function serializeNode(node: EditorNode): string { function serializeNode(node: EditorNode): string {
if (node.type === 'text') return node.text; if (node.type === 'text') return node.text;
if (node.type === 'mention') return `@[${node.attrs.type}:${node.attrs.id}:${node.attrs.label}]`; if (node.type === 'mention') return `@[${node.attrs.type}:${node.attrs.id}:${node.attrs.label}]`;
if (node.type === 'hardBreak') return '\n'; if (node.type === 'hardBreak') return '\n';
if (node.type === 'file') return ''; // files are sent separately via attachmentIds if (node.type === 'file') return ''; // files are sent separately via attachmentIds
if (node.type === 'emoji') return `[emoji:${node.attrs.name}]`; if (node.type === 'emoji') return `[emoji:${node.attrs.name}]`;
// Recurse into container nodes (paragraph, bulletList, etc.) // Recurse into container nodes (paragraph, bulletList, etc.)
const children = (node as any).content as EditorNode[] | undefined; const children = (node as any).content as EditorNode[] | undefined;
if (children) return children.map(serializeNode).join(''); if (children) return children.map(serializeNode).join('');
return ''; return '';
} }
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);
// Expose a subset of IMEditorHandle (plus getAttachmentIds) as MessageInputHandle // Expose a subset of IMEditorHandle (plus getAttachmentIds) as MessageInputHandle
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focus: () => innerEditorRef.current?.focus(), focus: () => innerEditorRef.current?.focus(),
clearContent: () => innerEditorRef.current?.clearContent(), clearContent: () => innerEditorRef.current?.clearContent(),
getContent: () => innerEditorRef.current?.getContent() ?? '', getContent: () => innerEditorRef.current?.getContent() ?? '',
insertMention: (type: string, id: string, label: string) => insertMention: (type: string, id: string, label: string) =>
innerEditorRef.current?.insertMention(type, id, label), innerEditorRef.current?.insertMention(type, id, label),
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [], getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
}), []); }), []);
// Transform room data into MentionItems — memoized to prevent IMEditor re-creation // Typing indicator: debounce start/stop
const mentionItems = useMemo(() => ({ const typingStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
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 sendTypingStart = useCallback(() => {
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => { if (!wsClient || !activeRoomId) return;
if (!activeRoomId) throw new Error('No active room'); if (typingStopTimerRef.current) {
const formData = new FormData(); clearTimeout(typingStopTimerRef.current);
formData.append('file', file); typingStopTimerRef.current = null;
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin; }
const res = await fetch(`${baseUrl}/rooms/${activeRoomId}/upload`, { method: 'POST', body: formData }); wsClient.sendTyping(activeRoomId, 'start');
if (!res.ok) throw new Error('Upload failed'); }, [wsClient, activeRoomId]);
return res.json();
};
// onSend: serialize AST to backend-parseable format const sendTypingStop = useCallback(() => {
const handleSend = (_text: string, ast: MessageAST) => { if (!wsClient || !activeRoomId) return;
const serialized = serializeMessageAst(ast); if (typingStopTimerRef.current) {
onSend(serialized); clearTimeout(typingStopTimerRef.current);
}; typingStopTimerRef.current = null;
}
wsClient.sendTyping(activeRoomId, 'stop');
}, [wsClient, activeRoomId]);
return ( const handleEditorUpdate = useCallback((text: string) => {
<IMEditor if (!text.trim()) {
ref={innerEditorRef} // Ignore empty updates (e.g. TipTap fires onUpdate("") on init).
replyingTo={replyingTo} // Only stop typing on explicit clear or send.
onCancelReply={onCancelReply} return;
onSend={handleSend} }
mentionItems={mentionItems} sendTypingStart();
onUploadFile={handleUploadFile} // Auto-stop after 1.5s of inactivity
placeholder={`Message #${roomName}`} 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[]; 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,65 +563,118 @@ 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 });
setStreamingContent((prev) => { }
const next = new Map(prev);
const prevContent = next.get(chunk.message_id) ?? ''; if (chunk.chunk_type === 'thinking') {
// Only append the delta (the part of chunk.content that is NEW). // Accumulate thinking content separately
// This prevents double-accumulation since backend already sends cumulative text. setStreamingThinkingContent((prev) => {
const newContent = const next = new Map(prev);
prevContent === '' || !chunk.content.startsWith(prevContent) const prevContent = next.get(chunk.message_id) ?? '';
? chunk.content // First chunk or content diverged — use as-is const newContent =
: prevContent + chunk.content.slice(prevContent.length); // Append delta prevContent === '' || !chunk.content.startsWith(prevContent)
next.set(chunk.message_id, newContent); ? 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) => { setMessages((msgs) => {
const idx = msgs.findIndex((m) => m.id === chunk.message_id); const idx = msgs.findIndex((m) => m.id === chunk.message_id);
if (idx !== -1) { if (idx !== -1) return msgs;
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 = { const newMsg: MessageWithMeta = {
id: chunk.message_id, id: chunk.message_id,
room: chunk.room_id, room: chunk.room_id,
seq: 0, seq: 0,
sender_type: 'ai', sender_type: 'ai',
display_name: chunk.display_name, display_name: chunk.display_name,
content: newContent, content: '',
display_content: newContent, display_content: '',
content_type: 'text', content_type: 'text',
send_at: new Date().toISOString(), send_at: new Date().toISOString(),
is_streaming: true, is_streaming: true,
chunk_type: chunk.chunk_type, chunk_type: 'thinking',
}; };
return [...msgs, newMsg]; 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) => { onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
@ -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 {