import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { type ProjectRepositoryItem, type RoomCategoryResponse, type RoomMemberResponse, type RoomMessageResponse, type RoomPinResponse, type RoomResponse, type RoomThreadResponse, categoryList as restCategoryList, roomList as restRoomList, } from '@/client'; import { createRoomWsClient, type RoomWsClient, type RoomWsStatus, type RoomMessagePayload, type RoomCategoryResponse as WsRoomCategoryResponse, type RoomReactionUpdatedPayload, type ReactionListData, } from '@/lib/room-ws-client'; import { requestWsToken } from '@/lib/ws-token'; import { useUser } from '@/contexts'; export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client'; export type PresenceStatus = 'online' | 'away' | 'dnd' | 'offline'; export type PresenceMap = Record; // keyed by user_id export interface RoomAiConfig { model: string; modelName?: string; } export interface ReactionGroup { emoji: string; count: number; reacted_by_me: boolean; users: string[]; } const MAX_MESSAGES_IN_MEMORY = 1000; function handleRoomError(context: string, error: unknown): void { const message = error instanceof Error ? error.message : 'Operation failed'; console.error(`[RoomContext] ${context}:`, error); toast.error(`${context} failed`, { description: message, }); } export type MessageWithMeta = RoomMessageResponse & { thread_id?: string; display_content?: string; is_streaming?: boolean; isOptimisticError?: boolean; /** True for messages sent by the current user that haven't been confirmed by the server */ isOptimistic?: boolean; reactions?: ReactionGroup[]; /** Attachment IDs for files uploaded with this message */ attachment_ids?: string[]; /** AI stream chunk type: "thinking", "tool_call", "tool_result", or undefined for normal text */ chunk_type?: string; }; export type RoomWithCategory = RoomResponse & { category_info?: RoomCategoryResponse | null; }; export type UiMessage = MessageWithMeta; function wsMessageToUiMessage(wsMsg: RoomMessagePayload): MessageWithMeta { return { id: wsMsg.id, seq: wsMsg.seq, room: wsMsg.room_id, sender_type: wsMsg.sender_type, sender_id: wsMsg.sender_id, display_name: wsMsg.display_name, thread: wsMsg.thread_id, thread_id: wsMsg.thread_id, content: wsMsg.content, content_type: wsMsg.content_type, send_at: wsMsg.send_at, display_content: wsMsg.content, is_streaming: false, reactions: wsMsg.reactions, }; } interface RoomContextValue { wsStatus: RoomWsStatus; wsError: string | null; wsClient: RoomWsClient | null; connectWs: () => Promise; disconnectWs: () => void; presence: PresenceMap; rooms: RoomWithCategory[]; roomsLoading: boolean; roomsError: Error | null; refreshRooms: () => Promise; categories: RoomCategoryResponse[]; categoriesLoading: boolean; refreshCategories: () => Promise; createCategory: (name: string, position?: number) => Promise; updateCategory: (id: string, name?: string, position?: number) => Promise; deleteCategory: (id: string) => Promise; activeRoom: RoomResponse | null; activeRoomId: string | null; setActiveRoom: (roomId: string | null) => void; messages: MessageWithMeta[]; messagesLoading: boolean; isHistoryLoaded: boolean; isLoadingMore: boolean; isTransitioningRoom: boolean; nextCursor: number | null; loadMore: (cursor?: number | null) => void; sendMessage: (content: string, contentType?: string, inReplyTo?: string, attachmentIds?: string[]) => Promise; editMessage: (messageId: string, content: string) => Promise; revokeMessage: (messageId: string) => Promise; updateReadSeq: (seq: number) => Promise; members: RoomMemberResponse[]; membersLoading: boolean; refreshMembers: () => Promise; addMember: (userId: string, role?: string) => Promise; removeMember: (userId: string) => Promise; updateMemberRole: (userId: string, role: string) => Promise; pins: RoomPinResponse[]; pinsLoading: boolean; refreshPins: () => Promise; pinMessage: (messageId: string) => Promise; unpinMessage: (messageId: string) => Promise; threads: RoomThreadResponse[]; refreshThreads: () => Promise; createThread: (parentSeq: number) => Promise; createRoom: (name: string, isPublic: boolean, categoryId?: string) => Promise; updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise; deleteRoom: (roomId: string) => Promise; streamingMessages: Map; /** Project repositories for @repository: mention suggestions */ projectRepos: ProjectRepositoryItem[]; reposLoading?: boolean; /** Room AI configs for @ai: mention suggestions */ roomAiConfigs: RoomAiConfig[]; aiConfigsLoading?: boolean; /** Typing users in the active room: roomId -> userId -> { username, avatar_url } */ typingUsers: Record }>>; } const RoomContext = createContext(null); interface RoomProviderProps { projectName: string | null; /** Room to open immediately on mount */ initialRoomId?: string | null; children: ReactNode; } export function RoomProvider({ projectName, initialRoomId = null, children, }: RoomProviderProps) { const { user } = useUser(); const navigate = useNavigate(); const [activeRoomId, setActiveRoomId] = useState(initialRoomId); const [wsClient, setWsClient] = useState(null); const wsClientRef = useRef(null); const activeRoomIdRef = useRef(activeRoomId); const [wsStatus, setWsStatus] = useState('idle'); const [wsError, setWsError] = useState(null); const [wsToken, setWsToken] = useState(null); // Buffer for messages received while user is in a different room (Bug 3 fix). // Merged into state when the user switches to that room. const pendingRoomMessagesRef = useRef>(new Map()); // Keep ref updated with latest activeRoomId activeRoomIdRef.current = activeRoomId; useEffect(() => { let cancelled = false; const fetchToken = async () => { try { const token = await requestWsToken(); if (!cancelled) { setWsToken(token); } } catch (error) { console.error('[RoomContext] Failed to fetch WS token:', error); // Token fetch is not critical - client can fall back to cookie auth if (!cancelled) { setWsToken(null); } } }; fetchToken(); return () => { cancelled = true; }; }, []); const [rooms, setRooms] = useState([]); const [roomsLoading, setRoomsLoading] = useState(false); const [roomsError, setRoomsError] = useState(null); const [categories, setCategories] = useState([]); const [categoriesLoading, setCategoriesLoading] = useState(false); // Merge category_info into rooms whenever either changes const roomsWithCategory = useMemo(() => { const catMap = new Map(categories.map((c) => [c.id, c])); return rooms.map((r) => ({ ...r, category_info: r.category ? (catMap.get(r.category) ?? null) : null, })); }, [rooms, categories]); const [activeRoom, setActiveRoomState] = useState(null); const [messages, setMessages] = useState([]); const [isHistoryLoaded, setIsHistoryLoaded] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [nextCursor, setNextCursor] = useState(null); const loadMessagesAbortRef = useRef(null); const prevRoomIdRef = useRef(null); const [isTransitioningRoom, setIsTransitioningRoom] = useState(false); useEffect(() => { if (prevRoomIdRef.current !== activeRoomId) { prevRoomIdRef.current = activeRoomId; loadMessagesAbortRef.current?.abort(); loadMessagesAbortRef.current = null; // Mark as transitioning room, show loading state instead of clearing immediately setIsTransitioningRoom(true); // Immediately clear old room's messages from state to prevent flicker setMessages([]); setIsHistoryLoaded(false); setNextCursor(null); // Merge any buffered messages for the new room (Bug 3 fix) if (activeRoomId) { const pending = pendingRoomMessagesRef.current.get(activeRoomId); if (pending && pending.length > 0) { pendingRoomMessagesRef.current.delete(activeRoomId); setMessages((prev) => { const merged = [...prev, ...pending.map(wsMessageToUiMessage)]; merged.sort((a, b) => a.seq - b.seq); return merged; }); } } // NOTE: intentionally NOT clearing IndexedDB — keeping it enables instant // load when the user returns to this room without waiting for API. } }, [activeRoomId]); // ── Subscribe to room (WS must already be connected) ─────────────────────── useEffect(() => { const client = wsClientRef.current; if (!activeRoomId || !client) return; // Load messages via WS (with HTTP fallback) loadMore(null); // Subscribe to room events. connect() is already called at the provider // level — subscribe/unsubscribe only manage per-room event routing. client.subscribeRoom(activeRoomId).catch(() => {}); return () => { client.unsubscribeRoom(activeRoomId).catch(() => {}); }; }, [activeRoomId, wsClient]); /** * Fetch reactions for a batch of messages via WS (with HTTP fallback), * then merge them into the messages state. Fires-and-forgets so it * does not block the caller. */ const thisLoadReactions = ( roomId: string, client: RoomWsClient, msgs: MessageWithMeta[], ) => { const msgIds = msgs.map((m) => m.id); if (msgIds.length === 0) return; const doLoad = async () => { let reactionResults: ReactionListData[]; if (client.getStatus() === 'open') { try { reactionResults = await client.reactionListBatchWs(roomId, msgIds); } catch { reactionResults = await client.reactionListBatch(roomId, msgIds); } } else { reactionResults = await client.reactionListBatch(roomId, msgIds); } const reactionMap = new Map(); for (const result of reactionResults) { if (result.reactions.length > 0) { reactionMap.set(result.message_id, result.reactions); } } if (reactionMap.size > 0) { setMessages((prev) => prev.map((m) => reactionMap.has(m.id) ? { ...m, reactions: reactionMap.get(m.id) } : m, ), ); } }; doLoad().catch(() => {}); }; const loadMore = useCallback( async (cursor?: number | null) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; // Cancel any in-flight request loadMessagesAbortRef.current?.abort(); loadMessagesAbortRef.current = new AbortController(); const abortController = loadMessagesAbortRef.current; setIsLoadingMore(true); try { const isInitial = cursor === null || cursor === undefined; const limit = isInitial ? 200 : 50; // Try WebSocket first; fall back to HTTP on failure let resp: import('@/lib/room-ws-client').RoomMessageListResponse; if (client.getStatus() === 'open') { try { resp = await client.messageListWs(activeRoomId, { beforeSeq: cursor ?? undefined, limit, }); } catch { // WS failed — fall back to HTTP resp = await client.messageList(activeRoomId, { beforeSeq: cursor ?? undefined, limit, }); } } else { resp = await client.messageList(activeRoomId, { beforeSeq: cursor ?? undefined, limit, }); } if (abortController.signal.aborted) return; const newMessages = resp.messages.map((m) => ({ ...m, thread_id: m.thread, display_content: m.content, is_streaming: false, })); setMessages((prev) => { if (abortController.signal.aborted) return prev; if (isInitial) { setIsTransitioningRoom(false); return newMessages; } const existingIds = new Set(prev.map((m) => m.id)); const filtered = newMessages.filter((m) => !existingIds.has(m.id)); let merged = [...filtered, ...prev]; merged.sort((a, b) => a.seq - b.seq); if (merged.length > MAX_MESSAGES_IN_MEMORY) { merged = merged.slice(-MAX_MESSAGES_IN_MEMORY); } return merged; }); if (resp.messages.length < limit) { setIsHistoryLoaded(true); } setNextCursor(resp.messages.length > 0 ? resp.messages[resp.messages.length - 1].seq : null); // Fetch reactions for all loaded messages thisLoadReactions(activeRoomId, client, newMessages); } catch (error) { if (abortController.signal.aborted) return; handleRoomError('Load messages', error); } finally { setIsLoadingMore(false); loadMessagesAbortRef.current = null; } }, [activeRoomId], ); const [members, setMembers] = useState([]); const [membersLoading, setMembersLoading] = useState(false); const [pins, setPins] = useState([]); const [pinsLoading, setPinsLoading] = useState(false); const [threads, setThreads] = useState([]); // User presence map: user_id -> status const [presence, setPresence] = useState({}); // Typing users map: roomId -> Map const [typingUsers, setTypingUsers] = useState }>>>({}); const [streamingContent, setStreamingContent] = useState>(new Map()); // Streaming timeout: if no chunk received for 60s, force-end the stream // to prevent UI hanging forever when done=true is never delivered. const streamingTimersRef = useRef>>(new Map()); const clearStreamingTimer = useCallback((msgId: string) => { const timer = streamingTimersRef.current.get(msgId); if (timer) { clearTimeout(timer); streamingTimersRef.current.delete(msgId); } }, []); const startStreamingTimer = useCallback((msgId: string) => { clearStreamingTimer(msgId); const timer = setTimeout(() => { // Force-end: mark message as not-streaming and keep whatever content we have setStreamingContent((prev) => { prev.delete(msgId); return new Map(prev); }); setMessages((prev) => prev.map((m) => m.id === msgId && m.is_streaming ? { ...m, is_streaming: false, content: m.content || '[Stream timed out — no completion signal received]' } : m, ), ); streamingTimersRef.current.delete(msgId); }, 60000); streamingTimersRef.current.set(msgId, timer); }, []); // Project repos for @repository: mention suggestions const [projectRepos, setProjectRepos] = useState([]); const [reposLoading, setReposLoading] = useState(false); // Room AI configs for @ai: mention suggestions const [roomAiConfigs, setRoomAiConfigs] = useState([]); const [aiConfigsLoading, setAiConfigsLoading] = useState(false); // ── Update WS token on existing client (instead of recreating client) ──────── useEffect(() => { if (wsToken && wsClientRef.current) { wsClientRef.current.setWsToken(wsToken); } }, [wsToken]); // ── Create WS client ONCE on mount ────────────────────────────────────────── // Recreating the client on wsToken change caused multiple invalid connections // (Bug 1). Instead, create once and update the token in-place. useEffect(() => { const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin; const client = createRoomWsClient( baseUrl, { onRoomMessage: (payload) => { // Use ref to get current activeRoomId to avoid stale closure if (payload.room_id === activeRoomIdRef.current) { setMessages((prev) => { const existingIdx = prev.findIndex((m) => m.id === payload.id); if (existingIdx !== -1) { // Message already exists (e.g. created by streaming chunk) — // merge server-side fields (display_name, reactions) that the // chunk didn't have. const existing = prev[existingIdx]; const needsUpdate = (!existing.display_name && payload.display_name) || (payload.reactions !== undefined && existing.reactions === undefined); if (needsUpdate) { const updated = [...prev]; updated[existingIdx] = { ...existing, display_name: payload.display_name ?? existing.display_name, reactions: payload.reactions ?? existing.reactions, }; return updated; } return prev; } // Replace optimistic message with server-confirmed one. // Streaming messages have seq=0 in optimistic state, so match by id instead. const optimisticIdx = prev.findIndex( (m) => m.isOptimistic && m.id === payload.id, ); if (optimisticIdx !== -1) { const confirmed: MessageWithMeta = { ...wsMessageToUiMessage(payload), reactions: prev[optimisticIdx].reactions, }; const next = [...prev]; next[optimisticIdx] = confirmed; return next; } const newMsg = wsMessageToUiMessage(payload); let updated = [...prev, newMsg]; updated.sort((a, b) => a.seq - b.seq); if (updated.length > MAX_MESSAGES_IN_MEMORY) { updated = updated.slice(-MAX_MESSAGES_IN_MEMORY); } return updated; }); } else { // Buffer messages for non-active rooms (Bug 3 fix). // When user switches to that room, pending messages are merged. pendingRoomMessagesRef.current.set(payload.room_id, [ ...pendingRoomMessagesRef.current.get(payload.room_id) ?? [], payload, ]); } }, onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string; chunk_type?: string }) => { if (chunk.done) { // Clear the timeout timer since stream completed normally clearStreamingTimer(chunk.message_id); // When done: clear streaming content, set is_streaming=false, and // update seq so the subsequent RoomMessage event deduplicates correctly. setStreamingContent((prev) => { prev.delete(chunk.message_id); return new Map(prev); }); setMessages((prev) => prev.map((m) => m.id === chunk.message_id ? { ...m, content: chunk.content, display_content: chunk.content, is_streaming: false, chunk_type: chunk.chunk_type } : m, ), ); } else { // Reset the timeout timer on each chunk — stream is still alive startStreamingTimer(chunk.message_id); // Single atomic update: accumulate in streamingContent AND update message. // Backend sends CUMULATIVE content (text_accumulated.clone()), not delta. // Use deduplication to only add the new delta portion. setStreamingContent((prev) => { const next = new Map(prev); const prevContent = next.get(chunk.message_id) ?? ''; // Only append the delta (the part of chunk.content that is NEW). // This prevents double-accumulation since backend already sends cumulative text. const newContent = prevContent === '' || !chunk.content.startsWith(prevContent) ? chunk.content // First chunk or content diverged — use as-is : prevContent + chunk.content.slice(prevContent.length); // Append delta next.set(chunk.message_id, newContent); setMessages((msgs) => { const idx = msgs.findIndex((m) => m.id === chunk.message_id); if (idx !== -1) { const m = msgs[idx]; if (m.content === newContent && m.is_streaming === true) return msgs; const updated = [...msgs]; updated[idx] = { ...m, content: newContent, display_content: newContent }; return updated; } if (!newContent) return msgs; const newMsg: MessageWithMeta = { id: chunk.message_id, room: chunk.room_id, seq: 0, sender_type: 'ai', display_name: chunk.display_name, content: newContent, display_content: newContent, content_type: 'text', send_at: new Date().toISOString(), is_streaming: true, chunk_type: chunk.chunk_type, }; return [...msgs, newMsg]; }); return next; }); } }, onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => { if (payload.room_id !== activeRoomIdRef.current) return; setMessages((prev) => { const existingIdx = prev.findIndex((m) => m.id === payload.message_id); if (existingIdx === -1) return prev; const updated = [...prev]; updated[existingIdx] = { ...updated[existingIdx], reactions: payload.reactions }; return updated; }); }, onMessageEdited: async (payload) => { if (payload.room_id !== activeRoomIdRef.current) return; const client = wsClientRef.current; if (!client) return; let rollbackEditedAt: string | null = null; setMessages((prev) => { const msg = prev.find((m) => m.id === payload.message_id); rollbackEditedAt = msg?.edited_at ?? null; return prev.map((m) => m.id === payload.message_id ? { ...m, edited_at: payload.edited_at } : m, ); }); try { const updatedMsg = await client.messageGet(payload.message_id); if (!updatedMsg) return; setMessages((prev) => prev.map((m) => m.id === payload.message_id ? { ...m, content: updatedMsg.content, display_content: updatedMsg.content, edited_at: payload.edited_at, } : m, ), ); } catch { if (rollbackEditedAt !== null) { setMessages((prev) => prev.map((m) => m.id === payload.message_id ? { ...m, edited_at: rollbackEditedAt! } : m, ), ); } } }, onMessageRevoked: async (payload) => { if (payload.room_id !== activeRoomIdRef.current) return; setMessages((prev) => prev.map((m) => m.id === payload.message_id ? { ...m, revoked: payload.revoked_at, revoked_by: payload.revoked_by, content: '', display_content: '', } : m, ), ); }, onMessagePinned: async (payload) => { if (payload.room_id !== activeRoomIdRef.current) return; const client = wsClientRef.current; if (!client) return; try { const pins = await client.pinList(payload.room_id); setPins(pins.map((p) => ({ room: p.room, message: p.message, pinned_by: p.pinned_by, pinned_at: p.pinned_at, }))); } catch { // Silently ignore } }, onMessageUnpinned: async (payload) => { if (payload.room_id !== activeRoomIdRef.current) return; setPins((prev) => prev.filter((p) => p.message !== payload.message_id)); }, onUserPresence: (payload) => { if (payload.room_id !== activeRoomIdRef.current) return; setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status })); }, onTypingStart: (payload) => { if (payload.room_id !== activeRoomIdRef.current) return; if (payload.user_id === user?.uid) return; // Don't show self 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); return { ...prev, [payload.room_id]: { ...roomMap, [payload.user_id]: { username: payload.username, avatar_url: payload.avatar_url, timeoutId }, }, }; }); }, onTypingStop: (payload) => { if (payload.room_id !== activeRoomIdRef.current) return; setTypingUsers((prev) => { const roomMap = prev[payload.room_id] ?? {}; const existing = roomMap[payload.user_id]; if (existing?.timeoutId) clearTimeout(existing.timeoutId); const newRoomMap = { ...roomMap }; delete newRoomMap[payload.user_id]; return { ...prev, [payload.room_id]: newRoomMap }; }); }, onStatusChange: (status) => { setWsStatus(status); if (status === 'closed' || status === 'error') { setWsError('Connection lost'); } else { setWsError(null); } }, onError: (error) => { setWsError(error.message); }, }, ); setWsClient(client); wsClientRef.current = client; // Connect immediately — connect() fetches its own token if needed client.connect().catch((e) => { console.error('[RoomContext] WS connect error:', e); }); return () => { client.disconnect(); // Intentional disconnect on unmount — no reconnect wsClientRef.current = null; // Clear all streaming timeout timers for (const timer of streamingTimersRef.current.values()) { clearTimeout(timer); } streamingTimersRef.current.clear(); }; }, []); // ← empty deps: create once on mount const connectWs = useCallback(async () => { const client = wsClientRef.current; if (!client) return; try { await client.connect(); setWsError(null); } catch (e) { setWsError(e instanceof Error ? e.message : 'Failed to connect'); } }, []); const disconnectWs = useCallback(() => { wsClientRef.current?.disconnect(); }, []); const fetchRooms = useCallback(async () => { if (!projectName) { setRooms([]); return; } setRoomsLoading(true); setRoomsError(null); try { const resp = await restRoomList({ path: { project_name: projectName } }); const data = resp.data?.data; if (Array.isArray(data)) { setRooms(data.map((r) => ({ ...r, category_info: null }))); } else { setRooms([]); } } catch (err) { setRoomsError(err instanceof Error ? err : new Error('Failed to load rooms')); } finally { setRoomsLoading(false); } }, [projectName]); useEffect(() => { fetchRooms(); }, [fetchRooms]); const fetchCategories = useCallback(async () => { if (!projectName) return; setCategoriesLoading(true); try { const resp = await restCategoryList({ path: { project_name: projectName } }); const data = resp.data?.data; setCategories(Array.isArray(data) ? data : []); } catch (error) { handleRoomError('Load categories', error); } finally { setCategoriesLoading(false); } }, [projectName]); useEffect(() => { fetchCategories(); }, [fetchCategories]); const createCategory = useCallback( async (name: string, position?: number) => { const client = wsClientRef.current; if (!projectName || !client) throw new Error('No project'); const cat = await client.categoryCreate(projectName, name, position); setCategories((prev) => [...prev, cat]); return cat; }, [projectName], ); const updateCategory = useCallback( async (id: string, name?: string, position?: number) => { const client = wsClientRef.current; if (!client) return; await client.categoryUpdate(id, { name, position }); setCategories((prev) => prev.map((c) => c.id === id ? { ...c, ...(name !== undefined && { name }), ...(position !== undefined && { position }) } : c, ), ); }, [], ); const deleteCategory = useCallback( async (id: string) => { const client = wsClientRef.current; if (!client) return; await client.categoryDelete(id); setCategories((prev) => prev.filter((c) => c.id !== id)); }, [], ); const setActiveRoom = useCallback( (roomId: string | null) => { setActiveRoomId(roomId); if (roomId) { navigate(`../room/${roomId}`, { replace: true }); } else { navigate('../room', { replace: true }); } }, [navigate], ); useEffect(() => { const client = wsClientRef.current; if (!activeRoomId || !client) { setActiveRoomState(null); return; } client .roomGet(activeRoomId) .then((resp) => resp && setActiveRoomState(resp)) .catch(() => setActiveRoomState(null)); }, [activeRoomId]); const fetchMembers = useCallback(async () => { const client = wsClientRef.current; if (!activeRoomId || !client) { setMembers([]); return; } setMembersLoading(true); try { const resp = await client.memberList(activeRoomId); setMembers(resp.map((m) => ({ ...m, do_not_disturb: false }))); } catch (error) { handleRoomError('Load members', error); setMembers([]); } finally { setMembersLoading(false); } }, [activeRoomId]); useEffect(() => { fetchMembers(); }, [fetchMembers]); const addMember = useCallback( async (userId: string, role = 'member') => { const client = wsClientRef.current; if (!activeRoomId || !client) return; const tempMember: RoomMemberResponse = { room: activeRoomId, user: userId, role, user_info: undefined, do_not_disturb: false, }; setMembers((prev) => [...prev, tempMember]); try { await client.memberAdd(activeRoomId, userId, role); await fetchMembers(); } catch (error) { handleRoomError('Add member', error); setMembers((prev) => prev.filter((m) => m.user !== userId)); } }, [activeRoomId, fetchMembers], ); const removeMember = useCallback( async (userId: string) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; await client.memberRemove(activeRoomId, userId); setMembers((prev) => prev.filter((m) => m.user !== userId)); }, [activeRoomId], ); const updateMemberRole = useCallback( async (userId: string, role: string) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; await client.memberUpdateRole(activeRoomId, userId, role); setMembers((prev) => prev.map((m) => (m.user === userId ? { ...m, role } : m))); }, [activeRoomId], ); // Guard against double-sending while a previous send is in-flight. // Without this, rapid clicking can queue multiple optimistic messages and // create duplicate sends on the server. const sendingRef = useRef(false); const sendMessage = useCallback( async (content: string, contentType = 'text', inReplyTo?: string, attachmentIds?: string[]) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; if (sendingRef.current) return; sendingRef.current = true; // Optimistic update: add message immediately so user sees it instantly const optimisticId = `optimistic-${crypto.randomUUID()}`; const optimisticMsg: MessageWithMeta = { id: optimisticId, room: activeRoomId, seq: 0, sender_type: 'member', sender_id: user?.uid ?? null, content, content_type: contentType, send_at: new Date().toISOString(), display_content: content, is_streaming: false, isOptimistic: true, thread: inReplyTo, thread_id: inReplyTo, in_reply_to: inReplyTo, reactions: [], attachment_ids: attachmentIds, }; setMessages((prev) => [...prev, optimisticMsg]); try { const confirmedMsg = await client.messageCreate(activeRoomId, content, { contentType, inReplyTo, attachmentIds, }); // Replace optimistic message with server-confirmed one setMessages((prev) => { const without = prev.filter((m) => m.id !== optimisticId); const confirmed: MessageWithMeta = { ...confirmedMsg, thread_id: confirmedMsg.thread, display_content: confirmedMsg.content, is_streaming: false, isOptimistic: false, reactions: [], }; return [...without, confirmed]; }); } catch (err) { // Mark optimistic message as failed setMessages((prev) => prev.map((m) => m.id === optimisticId ? { ...m, isOptimisticError: true } : m, ), ); handleRoomError('Send message', err); } finally { sendingRef.current = false; } }, [activeRoomId, user], ); const editMessage = useCallback( async (messageId: string, content: string) => { const client = wsClientRef.current; if (!client) return; let rollbackContent: string | null = null; setMessages((prev) => { const msg = prev.find((m) => m.id === messageId); rollbackContent = msg?.content ?? null; return prev.map((m) => m.id === messageId ? { ...m, content, display_content: content } : m, ); }); try { if (client.getStatus() === 'open') { try { await client.messageUpdateWs(messageId, content); } catch { await client.messageUpdate(messageId, content); } } else { await client.messageUpdate(messageId, content); } } catch (err) { if (rollbackContent !== null) { setMessages((prev) => prev.map((m) => m.id === messageId ? { ...m, content: rollbackContent!, display_content: rollbackContent! } : m, ), ); } handleRoomError('Edit message', err); } }, [], ); const revokeMessage = useCallback( async (messageId: string) => { const client = wsClientRef.current; if (!client) return; let rollbackMsg: MessageWithMeta | null = null; setMessages((prev) => { rollbackMsg = prev.find((m) => m.id === messageId) ?? null; return prev.filter((m) => m.id !== messageId); }); try { if (client.getStatus() === 'open') { try { await client.messageRevokeWs(messageId); } catch { await client.messageRevoke(messageId); } } else { await client.messageRevoke(messageId); } } catch (err) { if (rollbackMsg) { setMessages((prev) => [...prev, rollbackMsg!]); } handleRoomError('Delete message', err); } }, [], ); const updateReadSeq = useCallback( async (seq: number) => { const client = wsClientRef.current; if (!activeRoomId || !user || !client) return; try { await client.memberSetReadSeq(activeRoomId, seq); } catch (error) { console.warn('[RoomContext] Failed to update read position:', error); } }, [activeRoomId, user], ); const fetchPins = useCallback(async () => { const client = wsClientRef.current; if (!activeRoomId || !client) { setPins([]); return; } setPinsLoading(true); try { const resp = await client.pinList(activeRoomId); setPins( resp.map((p) => ({ room: p.room, message: p.message, pinned_by: p.pinned_by, pinned_at: p.pinned_at, })), ); } catch (error) { handleRoomError('Load pinned messages', error); setPins([]); } finally { setPinsLoading(false); } }, [activeRoomId]); useEffect(() => { fetchPins(); }, [fetchPins]); const pinMessage = useCallback( async (messageId: string) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; const tempPin: RoomPinResponse = { room: activeRoomId, message: messageId, pinned_by: '', pinned_at: new Date().toISOString(), }; setPins((prev) => [...prev, tempPin]); try { await client.pinAdd(activeRoomId, messageId); await fetchPins(); } catch (error) { handleRoomError('Pin message', error); setPins((prev) => prev.filter((p) => p.message !== messageId)); } }, [activeRoomId, fetchPins], ); const unpinMessage = useCallback( async (messageId: string) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; setPins((prev) => prev.filter((p) => p.message !== messageId)); try { await client.pinRemove(activeRoomId, messageId); } catch (error) { handleRoomError('Unpin message', error); fetchPins(); } }, [activeRoomId, fetchPins], ); const fetchThreads = useCallback(async () => { const client = wsClientRef.current; if (!activeRoomId || !client) { setThreads([]); return; } try { const resp = await client.threadList(activeRoomId); setThreads(resp); } catch (error) { handleRoomError('Load threads', error); setThreads([]); } }, [activeRoomId]); useEffect(() => { fetchThreads(); }, [fetchThreads]); const createThread = useCallback( async (parentSeq: number) => { const client = wsClientRef.current; if (!activeRoomId || !client) throw new Error('No active room'); const thread = await client.threadCreate(activeRoomId, parentSeq); setThreads((prev) => [...prev, thread]); return thread; }, [activeRoomId], ); // Fetch project repos for @repository: mention suggestions const fetchProjectRepos = useCallback(async () => { if (!projectName) { setProjectRepos([]); return; } setReposLoading(true); try { const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin; const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectName)}/repos`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const json: { data?: { items?: ProjectRepositoryItem[] } } = await resp.json(); setProjectRepos(json.data?.items ?? []); } catch { setProjectRepos([]); } finally { setReposLoading(false); } }, [projectName]); // Fetch room AI configs for @ai: mention suggestions const fetchRoomAiConfigs = useCallback(async () => { const client = wsClientRef.current; if (!activeRoomId || !client) { setRoomAiConfigs([]); return; } setAiConfigsLoading(true); try { const configs = await client.aiList(activeRoomId); setRoomAiConfigs( configs.map((cfg) => ({ model: cfg.model, modelName: cfg.model_name, })), ); } catch { setRoomAiConfigs([]); } finally { setAiConfigsLoading(false); } }, [activeRoomId]); useEffect(() => { fetchProjectRepos(); }, [fetchProjectRepos]); useEffect(() => { fetchRoomAiConfigs(); }, [fetchRoomAiConfigs]); const createRoom = useCallback( async (name: string, isPublic: boolean, categoryId?: string) => { const client = wsClientRef.current; if (!projectName || !client) throw new Error('No project'); const room = await client.roomCreate(projectName, name, isPublic, categoryId); setRooms((prev) => [...prev, { ...room, category_info: null }]); return room; }, [projectName], ); const updateRoom = useCallback( async (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => { const client = wsClientRef.current; if (!client) return; // Save previous values for rollback (use functional update to avoid closure issues) let rollbackRooms: RoomWithCategory[] | null = null; let rollbackActiveRoom: RoomResponse | null = null; // Capture current state for rollback setRooms((prev) => { rollbackRooms = prev; return prev.map((r) => r.id === roomId ? { ...r, ...(name !== undefined && { room_name: name }), ...(isPublic !== undefined && { public: isPublic }), ...(categoryId !== undefined && { category: categoryId }), } : r, ); }); if (activeRoomId === roomId) { setActiveRoomState((prev) => { rollbackActiveRoom = prev; return prev ? { ...prev, ...(name !== undefined && { room_name: name }), ...(isPublic !== undefined && { public: isPublic }), ...(categoryId !== undefined && { category: categoryId }), } : prev; }); } try { await client.roomUpdate(roomId, { roomName: name, isPublic, categoryId }); } catch (error) { handleRoomError('Update room', error); // Use captured snapshot for rollback, not the old value in closure if (rollbackRooms) { setRooms(rollbackRooms); } if (activeRoomId === roomId && rollbackActiveRoom) { setActiveRoomState(rollbackActiveRoom); } } }, [activeRoomId], ); const deleteRoom = useCallback( async (roomId: string) => { const client = wsClientRef.current; if (!client) return; await client.roomDelete(roomId); setRooms((prev) => prev.filter((r) => r.id !== roomId)); if (activeRoomId === roomId) { setActiveRoomId(null); setActiveRoom(null); } }, [activeRoomId], ); const value = useMemo( () => ({ wsStatus, wsError, wsClient: wsClientRef.current, connectWs, disconnectWs, rooms: roomsWithCategory, roomsLoading, roomsError, refreshRooms: fetchRooms, categories, categoriesLoading, refreshCategories: fetchCategories, createCategory, updateCategory, deleteCategory, activeRoom, activeRoomId, setActiveRoom, messages, messagesLoading: false, isHistoryLoaded, isLoadingMore, isTransitioningRoom, nextCursor, loadMore, sendMessage, editMessage, revokeMessage, updateReadSeq, members, membersLoading, refreshMembers: fetchMembers, addMember, removeMember, updateMemberRole, pins, pinsLoading, refreshPins: fetchPins, pinMessage, unpinMessage, presence, threads, refreshThreads: fetchThreads, createThread, createRoom, updateRoom, deleteRoom, streamingMessages: streamingContent, projectRepos, reposLoading, roomAiConfigs, aiConfigsLoading, typingUsers, }), [ wsStatus, wsError, connectWs, disconnectWs, wsClientRef.current, roomsWithCategory, roomsLoading, roomsError, fetchRooms, categories, categoriesLoading, fetchCategories, createCategory, updateCategory, deleteCategory, activeRoom, activeRoomId, setActiveRoom, messages, isHistoryLoaded, isLoadingMore, isTransitioningRoom, nextCursor, loadMore, sendMessage, editMessage, revokeMessage, updateReadSeq, members, membersLoading, fetchMembers, addMember, removeMember, updateMemberRole, pins, pinsLoading, fetchPins, pinMessage, unpinMessage, presence, threads, fetchThreads, createThread, createRoom, updateRoom, deleteRoom, streamingContent, projectRepos, reposLoading, roomAiConfigs, aiConfigsLoading, typingUsers, ], ); return {children}; } export function useRoom() { const ctx = useContext(RoomContext); if (!ctx) throw new Error('useRoom must be used within RoomProvider'); return ctx; }