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
This commit is contained in:
ZhenYi 2026-04-25 22:45:11 +08:00
parent 78eee672a4
commit 61210da7a1
5 changed files with 81 additions and 20 deletions

View File

@ -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') && (
<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) => (
@ -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 (
<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 }}>{name}</span>
{' is thinking...'}
</span>
</div>
);
})()}
<MessageInput
ref={messageInputRef}
roomName={room.room_name ?? 'room'}

View File

@ -95,7 +95,11 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
const typingStopTimerRef = useRef<ReturnType<typeof setTimeout> | 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<MessageInputHandle, MessageInputProps>(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);

View File

@ -170,8 +170,8 @@ interface RoomContextValue {
roomAiConfigs: RoomAiConfig[];
aiConfigsLoading?: boolean;
/** Typing users in the active room: roomId -> userId -> { username, avatar_url } */
typingUsers: Record<string, Record<string, { username: string; avatar_url?: string; timeoutId?: ReturnType<typeof setTimeout> }>>;
/** Typing users in the active room: roomId -> userId -> { username, avatar_url, sender_type } */
typingUsers: Record<string, Record<string, { username: string; avatar_url?: string; sender_type?: string; timeoutId?: ReturnType<typeof setTimeout> }>>;
}
const RoomContext = createContext<RoomContextValue | null>(null);
@ -435,8 +435,8 @@ export function RoomProvider({
// User presence map: user_id -> status
const [presence, setPresence] = useState<PresenceMap>({});
// 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> }>>>({});
// Typing users map: roomId -> Map<userId, { username, avatar_url, sender_type, timeoutId }>
const [typingUsers, setTypingUsers] = useState<Record<string, Record<string, { username: string; avatar_url?: string; sender_type?: string; timeoutId?: ReturnType<typeof setTimeout> }>>>({});
@ -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(() => {
// AI typing has explicit backend stop — no timeout needed.
// Human typing uses 4s client-side expiry as a fallback.
let timeoutId: ReturnType<typeof setTimeout> | 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();
}, []);

View File

@ -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?.({

View File

@ -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 {