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}
|
onCreateThread={handleCreateThread}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* AI thinking / generating indicator */}
|
{/* AI thinking / generating indicator — hidden when typingUsers already shows AI */}
|
||||||
{activeAiStream && (
|
{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)' }}>
|
<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">
|
<span className="flex gap-0.5">
|
||||||
{[0, 1, 2].map((i) => (
|
{[0, 1, 2].map((i) => (
|
||||||
@ -374,9 +374,9 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
|||||||
{/* Human typing indicator — show who is typing */}
|
{/* Human typing indicator — show who is typing */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const roomTyping = typingUsers?.[room.id] ?? {};
|
const roomTyping = typingUsers?.[room.id] ?? {};
|
||||||
const typingList = Object.entries(roomTyping);
|
const humanTyping = Object.entries(roomTyping).filter(([, v]) => v.sender_type !== 'ai');
|
||||||
if (typingList.length === 0) return null;
|
if (humanTyping.length === 0) return null;
|
||||||
const names = typingList.map(([, v]) => v.username);
|
const names = humanTyping.map(([, v]) => v.username);
|
||||||
const label = names.length === 1
|
const label = names.length === 1
|
||||||
? `${names[0]} is typing...`
|
? `${names[0]} is typing...`
|
||||||
: names.length === 2
|
: 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
|
<MessageInput
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
roomName={room.room_name ?? 'room'}
|
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 typingStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const sendTypingStart = useCallback(() => {
|
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) {
|
if (typingStopTimerRef.current) {
|
||||||
clearTimeout(typingStopTimerRef.current);
|
clearTimeout(typingStopTimerRef.current);
|
||||||
typingStopTimerRef.current = null;
|
typingStopTimerRef.current = null;
|
||||||
@ -118,6 +122,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
// Only stop typing on explicit clear or send.
|
// Only stop typing on explicit clear or send.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.debug('[MessageInput] handleEditorUpdate text_len:', text.length, 'ws:', !!wsClient, 'room:', activeRoomId);
|
||||||
sendTypingStart();
|
sendTypingStart();
|
||||||
// Auto-stop after 1.5s of inactivity
|
// Auto-stop after 1.5s of inactivity
|
||||||
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
|
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
|
||||||
|
|||||||
@ -170,8 +170,8 @@ interface RoomContextValue {
|
|||||||
roomAiConfigs: RoomAiConfig[];
|
roomAiConfigs: RoomAiConfig[];
|
||||||
aiConfigsLoading?: boolean;
|
aiConfigsLoading?: boolean;
|
||||||
|
|
||||||
/** Typing users in the active room: roomId -> userId -> { username, avatar_url } */
|
/** Typing users in the active room: roomId -> userId -> { username, avatar_url, sender_type } */
|
||||||
typingUsers: Record<string, Record<string, { username: string; avatar_url?: string; timeoutId?: ReturnType<typeof setTimeout> }>>;
|
typingUsers: Record<string, Record<string, { username: string; avatar_url?: string; sender_type?: string; timeoutId?: ReturnType<typeof setTimeout> }>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RoomContext = createContext<RoomContextValue | null>(null);
|
const RoomContext = createContext<RoomContextValue | null>(null);
|
||||||
@ -435,8 +435,8 @@ export function RoomProvider({
|
|||||||
// User presence map: user_id -> status
|
// User presence map: user_id -> status
|
||||||
const [presence, setPresence] = useState<PresenceMap>({});
|
const [presence, setPresence] = useState<PresenceMap>({});
|
||||||
|
|
||||||
// Typing users map: roomId -> Map<userId, { username, avatar_url, timeoutId }>
|
// 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; timeoutId?: ReturnType<typeof setTimeout> }>>>({});
|
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 }) => {
|
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';
|
const isToolCall = chunk.chunk_type === 'tool_call' || chunk.chunk_type === 'tool_result';
|
||||||
|
|
||||||
if (chunk.done) {
|
if (chunk.done) {
|
||||||
@ -767,25 +768,32 @@ export function RoomProvider({
|
|||||||
setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status }));
|
setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status }));
|
||||||
},
|
},
|
||||||
onTypingStart: (payload) => {
|
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.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) => {
|
setTypingUsers((prev) => {
|
||||||
const roomMap = prev[payload.room_id] ?? {};
|
const roomMap = prev[payload.room_id] ?? {};
|
||||||
// Clear existing timeout for this user
|
// Clear existing timeout for this user
|
||||||
const existing = roomMap[payload.user_id];
|
const existing = roomMap[payload.user_id];
|
||||||
if (existing?.timeoutId) clearTimeout(existing.timeoutId);
|
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) => {
|
setTypingUsers((p) => {
|
||||||
const rm = { ...p[payload.room_id] };
|
const rm = { ...p[payload.room_id] };
|
||||||
delete rm[payload.user_id];
|
delete rm[payload.user_id];
|
||||||
return { ...p, [payload.room_id]: rm };
|
return { ...p, [payload.room_id]: rm };
|
||||||
});
|
});
|
||||||
}, 4000);
|
}, 4000);
|
||||||
|
}
|
||||||
const next = {
|
const next = {
|
||||||
...prev,
|
...prev,
|
||||||
[payload.room_id]: {
|
[payload.room_id]: {
|
||||||
...roomMap,
|
...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;
|
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(() => {
|
const disconnectWs = useCallback(() => {
|
||||||
wsClientRef.current?.disconnect();
|
wsClientRef.current?.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@ -1054,13 +1054,15 @@ export class RoomWsClient {
|
|||||||
case 'room.typing':
|
case 'room.typing':
|
||||||
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') {
|
if (data?.action === 'start') {
|
||||||
this.callbacks.onTypingStart?.({
|
this.callbacks.onTypingStart?.({
|
||||||
room_id: event.room_id ?? '',
|
room_id: event.room_id ?? '',
|
||||||
user_id: data.user_id ?? '',
|
user_id: data.user_id ?? '',
|
||||||
username: data.username ?? '',
|
username: data.username ?? '',
|
||||||
avatar_url: data.avatar_url,
|
avatar_url: data.avatar_url,
|
||||||
|
sender_type: data.sender_type,
|
||||||
});
|
});
|
||||||
} else if (data?.action === 'stop') {
|
} else if (data?.action === 'stop') {
|
||||||
this.callbacks.onTypingStop?.({
|
this.callbacks.onTypingStop?.({
|
||||||
|
|||||||
@ -144,6 +144,8 @@ export interface TypingStartPayload {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
|
/** "user" or "ai". Defaults to "user". */
|
||||||
|
sender_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TypingStopPayload {
|
export interface TypingStopPayload {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user