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 {