From 61210da7a1bdb68ea56fcabe1a8e8ace8a9f125a Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 25 Apr 2026 22:45:11 +0800 Subject: [PATCH] feat(frontend): typing indicator with AI/human split, page visibility reconnect - TypingUsers state split by sender_type: AI vs human typing - AI typing shows "{Name} is thinking..." with accent color - Human typing shows "{Name} is typing..." with muted style - AI typing relies on backend 60s TTL stop (no client-side 4s fallback) - Add Page Visibility API to reconnect WS on tab become visible - Add debug logs for typing flow tracing - Pass sender_type through WS room.typing event routing --- src/components/room/DiscordChatPanel.tsx | 35 +++++++++++-- src/components/room/message/MessageInput.tsx | 7 ++- src/contexts/room-context.tsx | 53 +++++++++++++++----- src/lib/room-ws-client.ts | 4 +- src/lib/ws-protocol.ts | 2 + 5 files changed, 81 insertions(+), 20 deletions(-) diff --git a/src/components/room/DiscordChatPanel.tsx b/src/components/room/DiscordChatPanel.tsx index cfd7793..634e059 100644 --- a/src/components/room/DiscordChatPanel.tsx +++ b/src/components/room/DiscordChatPanel.tsx @@ -352,8 +352,8 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha onCreateThread={handleCreateThread} /> - {/* AI thinking / generating indicator */} - {activeAiStream && ( + {/* AI thinking / generating indicator — hidden when typingUsers already shows AI */} + {activeAiStream && !Object.entries(typingUsers?.[room.id] ?? {}).find(([, v]) => v.sender_type === 'ai') && (
{[0, 1, 2].map((i) => ( @@ -374,9 +374,9 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha {/* Human typing indicator — show who is typing */} {(() => { const roomTyping = typingUsers?.[room.id] ?? {}; - const typingList = Object.entries(roomTyping); - if (typingList.length === 0) return null; - const names = typingList.map(([, v]) => v.username); + const humanTyping = Object.entries(roomTyping).filter(([, v]) => v.sender_type !== 'ai'); + if (humanTyping.length === 0) return null; + const names = humanTyping.map(([, v]) => v.username); const label = names.length === 1 ? `${names[0]} is typing...` : names.length === 2 @@ -400,6 +400,31 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha ); })()} + {/* AI typing — distinct from human typing */} + {(() => { + const roomTyping = typingUsers?.[room.id] ?? {}; + const aiTyping = Object.entries(roomTyping).find(([, v]) => v.sender_type === 'ai'); + if (!aiTyping) return null; + const name = aiTyping[1].username; + return ( +
+ + {[0, 1, 2].map((i) => ( + + ))} + + + {name} + {' is thinking...'} + +
+ ); + })()} + (fu const typingStopTimerRef = useRef | null>(null); const sendTypingStart = useCallback(() => { - if (!wsClient || !activeRoomId) return; + if (!wsClient || !activeRoomId) { + console.debug('[MessageInput] sendTypingStart skipped: wsClient=', !!wsClient, 'activeRoomId=', activeRoomId); + return; + } + console.debug('[MessageInput] sendTypingStart room:', activeRoomId); if (typingStopTimerRef.current) { clearTimeout(typingStopTimerRef.current); typingStopTimerRef.current = null; @@ -118,6 +122,7 @@ export const MessageInput = forwardRef(fu // Only stop typing on explicit clear or send. return; } + console.debug('[MessageInput] handleEditorUpdate text_len:', text.length, 'ws:', !!wsClient, 'room:', activeRoomId); sendTypingStart(); // Auto-stop after 1.5s of inactivity if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current); diff --git a/src/contexts/room-context.tsx b/src/contexts/room-context.tsx index 1669890..8c931e4 100644 --- a/src/contexts/room-context.tsx +++ b/src/contexts/room-context.tsx @@ -170,8 +170,8 @@ interface RoomContextValue { roomAiConfigs: RoomAiConfig[]; aiConfigsLoading?: boolean; - /** Typing users in the active room: roomId -> userId -> { username, avatar_url } */ - typingUsers: Record }>>; + /** Typing users in the active room: roomId -> userId -> { username, avatar_url, sender_type } */ + typingUsers: Record }>>; } const RoomContext = createContext(null); @@ -435,8 +435,8 @@ export function RoomProvider({ // User presence map: user_id -> status const [presence, setPresence] = useState({}); - // Typing users map: roomId -> Map - const [typingUsers, setTypingUsers] = useState }>>>({}); + // Typing users map: roomId -> Map + const [typingUsers, setTypingUsers] = useState }>>>({}); @@ -563,6 +563,7 @@ export function RoomProvider({ } }, onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string; chunk_type?: string }) => { + console.debug('[RoomContext] onAiStreamChunk', chunk.chunk_type, chunk.done ? '(done)' : '', 'msg:', chunk.message_id); const isToolCall = chunk.chunk_type === 'tool_call' || chunk.chunk_type === 'tool_result'; if (chunk.done) { @@ -767,25 +768,32 @@ export function RoomProvider({ setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status })); }, onTypingStart: (payload) => { + console.debug('[RoomContext] onTypingStart', payload.room_id, 'user:', payload.user_id, 'username:', payload.username, 'currentRoom:', activeRoomIdRef.current, 'currentUser:', user?.uid); if (payload.room_id !== activeRoomIdRef.current) return; - if (payload.user_id === user?.uid) return; + // Skip own typing events (except AI — own AI stream should still show indicator). + if (payload.user_id === user?.uid && payload.sender_type !== 'ai') return; setTypingUsers((prev) => { const roomMap = prev[payload.room_id] ?? {}; // Clear existing timeout for this user const existing = roomMap[payload.user_id]; if (existing?.timeoutId) clearTimeout(existing.timeoutId); - const timeoutId = setTimeout(() => { - setTypingUsers((p) => { - const rm = { ...p[payload.room_id] }; - delete rm[payload.user_id]; - return { ...p, [payload.room_id]: rm }; - }); - }, 4000); + // AI typing has explicit backend stop — no timeout needed. + // Human typing uses 4s client-side expiry as a fallback. + let timeoutId: ReturnType | undefined; + if (payload.sender_type !== 'ai') { + timeoutId = setTimeout(() => { + setTypingUsers((p) => { + const rm = { ...p[payload.room_id] }; + delete rm[payload.user_id]; + return { ...p, [payload.room_id]: rm }; + }); + }, 4000); + } const next = { ...prev, [payload.room_id]: { ...roomMap, - [payload.user_id]: { username: payload.username, avatar_url: payload.avatar_url, timeoutId }, + [payload.user_id]: { username: payload.username, avatar_url: payload.avatar_url, sender_type: payload.sender_type, timeoutId }, }, }; return next; @@ -846,6 +854,25 @@ export function RoomProvider({ } }, []); + // Reconnect WS when tab becomes visible again after background throttling. + // Chrome heavily throttles setInterval in background tabs (1s granularity or pauses), + // so heartbeat may not fire in time, causing the backend to close the connection. + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + const client = wsClientRef.current; + if (client && client.getStatus() !== 'open') { + console.debug('[RoomContext] Tab visible, reconnecting WS...'); + client.connect().catch(() => {}); + } + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + const disconnectWs = useCallback(() => { wsClientRef.current?.disconnect(); }, []); diff --git a/src/lib/room-ws-client.ts b/src/lib/room-ws-client.ts index 2511e8d..6d88666 100644 --- a/src/lib/room-ws-client.ts +++ b/src/lib/room-ws-client.ts @@ -1054,13 +1054,15 @@ export class RoomWsClient { case 'room.typing': case 'room_typing': { - const data = event.data as { user_id?: string; username?: string; avatar_url?: string; action?: string } | undefined; + const data = event.data as { user_id?: string; username?: string; avatar_url?: string; action?: string; sender_type?: string } | undefined; + console.debug('[RoomWs] room.typing event:', data?.action, 'room:', event.room_id, 'user:', data?.user_id, 'username:', data?.username, 'sender:', data?.sender_type); 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, + sender_type: data.sender_type, }); } else if (data?.action === 'stop') { this.callbacks.onTypingStop?.({ diff --git a/src/lib/ws-protocol.ts b/src/lib/ws-protocol.ts index f4e6ce1..16d1d7e 100644 --- a/src/lib/ws-protocol.ts +++ b/src/lib/ws-protocol.ts @@ -144,6 +144,8 @@ export interface TypingStartPayload { user_id: string; username: string; avatar_url?: string; + /** "user" or "ai". Defaults to "user". */ + sender_type?: string; } export interface TypingStopPayload {