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