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:
parent
78eee672a4
commit
61210da7a1
@ -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'}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
}, []);
|
||||
|
||||
@ -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?.({
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user