From 73ba6329eaace3001193854e260ae2271800f27b Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 25 Apr 2026 20:09:03 +0800 Subject: [PATCH] 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 --- src/components/room/DiscordChatPanel.tsx | 22 +- src/components/room/message/MessageBubble.tsx | 95 ++++--- src/components/room/message/MessageInput.tsx | 241 +++++++++++------- src/contexts/room-context.tsx | 147 ++++++++--- src/lib/room-ws-client.ts | 25 +- src/lib/ws-protocol.ts | 5 +- 6 files changed, 349 insertions(+), 186 deletions(-) diff --git a/src/components/room/DiscordChatPanel.tsx b/src/components/room/DiscordChatPanel.tsx index 5e0f693..cfd7793 100644 --- a/src/components/room/DiscordChatPanel.tsx +++ b/src/components/room/DiscordChatPanel.tsx @@ -60,6 +60,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha roomAiConfigs, presence, typingUsers, + activeAiStream, } = useRoom(); const messagesEndRef = useRef(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 && ( +
+ + {[0, 1, 2].map((i) => ( + + ))} + + + {activeAiStream.display_name} + {' is thinking...'} + +
+ )} + + {/* Human typing indicator — show who is typing */} {(() => { const roomTyping = typingUsers?.[room.id] ?? {}; const typingList = Object.entries(roomTyping); diff --git a/src/components/room/message/MessageBubble.tsx b/src/components/room/message/MessageBubble.tsx index 1fbb878..3b023c7 100644 --- a/src/components/room/message/MessageBubble.tsx +++ b/src/components/room/message/MessageBubble.tsx @@ -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( - () => - 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({
{message.content_type === 'text' || message.content_type === 'Text' ? (
- {/* Thinking phase — rendered as collapsible, muted style */} - {message.chunk_type === 'thinking' && !functionCalls.length && ( -
- Thinking -
{displayContent}
+ {/* Thinking/reasoning section — collapsible, DeepSeek-style */} + {thinkingContent && ( +
+ + {thinkingExpanded && ( +
+ {thinkingContent} +
+ )}
)} - {/* Tool call phase — rendered as compact badge */} - {message.chunk_type === 'tool_call' && ( -
- - - Tool Call - -
{displayContent}
-
- )} - {/* Tool result phase — rendered as compact output */} - {message.chunk_type === 'tool_result' && ( -
- - - {displayContent.includes('[Tool call failed') ? 'Error' : 'Result'} - -
{displayContent}
-
- )} - {/* Normal answer or no chunk_type — default rendering */} - {!message.chunk_type && functionCalls.length > 0 ? ( - functionCalls.map((call, index) => ( -
- -
- )) - ) : !message.chunk_type ? ( + {/* Answer content — always visible */} + {displayContent && ( - ) : null} + content={displayContent} + onMentionClick={handleMentionClick} + /> + )} {/* Streaming cursor */} {isStreaming && } diff --git a/src/components/room/message/MessageInput.tsx b/src/components/room/message/MessageInput.tsx index f58d56d..19cec24 100644 --- a/src/components/room/message/MessageInput.tsx +++ b/src/components/room/message/MessageInput.tsx @@ -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(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(null); + // Ref passed to the inner IMEditor + const innerEditorRef = useRef(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 | 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 ( - - ); + 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 ( + + ); }); \ No newline at end of file diff --git a/src/contexts/room-context.tsx b/src/contexts/room-context.tsx index e5f8be4..1669890 100644 --- a/src/contexts/room-context.tsx +++ b/src/contexts/room-context.tsx @@ -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; deleteRoom: (roomId: string) => Promise; streamingMessages: Map; + /** Streaming thinking/reasoning content keyed by message_id */ + streamingThinkingContent: Map; + /** 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 const [typingUsers, setTypingUsers] = useState }>>>({}); + + const [streamingContent, setStreamingContent] = useState>(new Map()); + const [streamingThinkingContent, setStreamingThinkingContent] = useState>(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>>(new Map()); + // Ref to latest streamingThinkingContent so done handler can read it (setState is async) + const streamingThinkingContentRef = useRef>(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, diff --git a/src/lib/room-ws-client.ts b/src/lib/room-ws-client.ts index b4d1279..2511e8d 100644 --- a/src/lib/room-ws-client.ts +++ b/src/lib/room-ws-client.ts @@ -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(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?.({ diff --git a/src/lib/ws-protocol.ts b/src/lib/ws-protocol.ts index c968115..f4e6ce1 100644 --- a/src/lib/ws-protocol.ts +++ b/src/lib/ws-protocol.ts @@ -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 {