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:
parent
0a9dfef9b4
commit
73ba6329ea
@ -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);
|
||||
|
||||
@ -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" />}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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?.({
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user