gitdataai/src/contexts/room-context.tsx
ZhenYi 73ba6329ea fix(frontend): prevent typing.stop on editor init, add typing display
- MessageInput: ignore empty text in handleEditorUpdate to avoid
  TipTap's onUpdate("") on init clearing the typing state
- DiscordChatPanel: show typing indicator when other users are typing
- room-context: wire onTypingStart/Stop into ws callbacks
2026-04-25 20:09:03 +08:00

1529 lines
49 KiB
TypeScript

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<string, PresenceStatus>; // 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;
/** Accumulated thinking/reasoning content from AI stream (collapsible) */
thinking_content?: 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<void>;
disconnectWs: () => void;
presence: PresenceMap;
rooms: RoomWithCategory[];
roomsLoading: boolean;
roomsError: Error | null;
refreshRooms: () => Promise<void>;
categories: RoomCategoryResponse[];
categoriesLoading: boolean;
refreshCategories: () => Promise<void>;
createCategory: (name: string, position?: number) => Promise<WsRoomCategoryResponse>;
updateCategory: (id: string, name?: string, position?: number) => Promise<void>;
deleteCategory: (id: string) => Promise<void>;
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<void>;
editMessage: (messageId: string, content: string) => Promise<void>;
revokeMessage: (messageId: string) => Promise<void>;
updateReadSeq: (seq: number) => Promise<void>;
members: RoomMemberResponse[];
membersLoading: boolean;
refreshMembers: () => Promise<void>;
addMember: (userId: string, role?: string) => Promise<void>;
removeMember: (userId: string) => Promise<void>;
updateMemberRole: (userId: string, role: string) => Promise<void>;
pins: RoomPinResponse[];
pinsLoading: boolean;
refreshPins: () => Promise<void>;
pinMessage: (messageId: string) => Promise<void>;
unpinMessage: (messageId: string) => Promise<void>;
threads: RoomThreadResponse[];
refreshThreads: () => Promise<void>;
createThread: (parentSeq: number) => Promise<RoomThreadResponse>;
createRoom: (name: string, isPublic: boolean, categoryId?: string) => Promise<RoomResponse>;
updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise<void>;
deleteRoom: (roomId: string) => Promise<void>;
streamingMessages: Map<string, string>;
/** Streaming thinking/reasoning content keyed by message_id */
streamingThinkingContent: Map<string, string>;
/** Active AI stream info for typing indicator */
activeAiStream: { message_id: string; display_name: string } | null;
/** 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<string, Record<string, { username: string; avatar_url?: string; timeoutId?: ReturnType<typeof setTimeout> }>>;
}
const RoomContext = createContext<RoomContextValue | null>(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<string | null>(initialRoomId);
const [wsClient, setWsClient] = useState<RoomWsClient | null>(null);
const wsClientRef = useRef<RoomWsClient | null>(null);
const activeRoomIdRef = useRef<string | null>(activeRoomId);
const [wsStatus, setWsStatus] = useState<RoomWsStatus>('idle');
const [wsError, setWsError] = useState<string | null>(null);
const [wsToken, setWsToken] = useState<string | null>(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<Map<string, RoomMessagePayload[]>>(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<RoomWithCategory[]>([]);
const [roomsLoading, setRoomsLoading] = useState(false);
const [roomsError, setRoomsError] = useState<Error | null>(null);
const [categories, setCategories] = useState<RoomCategoryResponse[]>([]);
const [categoriesLoading, setCategoriesLoading] = useState(false);
// Merge category_info into rooms whenever either changes
const roomsWithCategory = useMemo<RoomWithCategory[]>(() => {
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<RoomResponse | null>(null);
const [messages, setMessages] = useState<MessageWithMeta[]>([]);
const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [nextCursor, setNextCursor] = useState<number | null>(null);
const loadMessagesAbortRef = useRef<AbortController | null>(null);
const prevRoomIdRef = useRef<string | null>(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<string, ReactionListData['reactions']>();
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<RoomMemberResponse[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const [pins, setPins] = useState<RoomPinResponse[]>([]);
const [pinsLoading, setPinsLoading] = useState(false);
const [threads, setThreads] = useState<RoomThreadResponse[]>([]);
// 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> }>>>({});
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
const [streamingThinkingContent, setStreamingThinkingContent] = useState<Map<string, string>>(new Map());
const [activeAiStream, setActiveAiStream] = useState<{ message_id: string; display_name: string } | null>(null);
// 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<Map<string, ReturnType<typeof setTimeout>>>(new Map());
// Ref to latest streamingThinkingContent so done handler can read it (setState is async)
const streamingThinkingContentRef = useRef<Map<string, string>>(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
setActiveAiStream((prev) => prev?.message_id === msgId ? null : prev);
setStreamingContent((prev) => {
prev.delete(msgId);
return new Map(prev);
});
setStreamingThinkingContent((prev) => {
prev.delete(msgId);
return new Map(prev);
});
streamingThinkingContentRef.current.delete(msgId);
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<ProjectRepositoryItem[]>([]);
const [reposLoading, setReposLoading] = useState(false);
// Room AI configs for @ai: mention suggestions
const [roomAiConfigs, setRoomAiConfigs] = useState<RoomAiConfig[]>([]);
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 }) => {
const isToolCall = chunk.chunk_type === 'tool_call' || chunk.chunk_type === 'tool_result';
if (chunk.done) {
// Clear the timeout timer since stream completed normally
clearStreamingTimer(chunk.message_id);
// Set activeAiStream to null since streaming is done
setActiveAiStream(null);
// Clear streaming content maps
setStreamingContent((prev) => {
prev.delete(chunk.message_id);
return new Map(prev);
});
setStreamingThinkingContent((prev) => {
prev.delete(chunk.message_id);
return new Map(prev);
});
// Finalize message: keep thinking_content from accumulator, set content from done chunk
setMessages((prev) =>
prev.map((m) => {
if (m.id !== chunk.message_id) return m;
// Get thinking_content from the accumulator before it was cleared
const tc = streamingThinkingContentRef.current.get(chunk.message_id);
return {
...m,
content: chunk.content,
display_content: chunk.content,
is_streaming: false,
thinking_content: tc ?? m.thinking_content,
chunk_type: chunk.chunk_type,
};
}),
);
} else {
// Reset the timeout timer on each chunk — stream is still alive
startStreamingTimer(chunk.message_id);
// Update activeAiStream for typing indicator (skip tool call / result)
if (!isToolCall && chunk.display_name) {
setActiveAiStream({ message_id: chunk.message_id, display_name: chunk.display_name });
}
if (chunk.chunk_type === 'thinking') {
// Accumulate thinking content separately
setStreamingThinkingContent((prev) => {
const next = new Map(prev);
const prevContent = next.get(chunk.message_id) ?? '';
const newContent =
prevContent === '' || !chunk.content.startsWith(prevContent)
? chunk.content
: prevContent + chunk.content.slice(prevContent.length);
next.set(chunk.message_id, newContent);
// Sync ref for done handler access
streamingThinkingContentRef.current = new Map(next);
return next;
});
// Ensure message entry exists (with minimal content to show streaming state)
setMessages((msgs) => {
const idx = msgs.findIndex((m) => m.id === chunk.message_id);
if (idx !== -1) return msgs;
const newMsg: MessageWithMeta = {
id: chunk.message_id,
room: chunk.room_id,
seq: 0,
sender_type: 'ai',
display_name: chunk.display_name,
content: '',
display_content: '',
content_type: 'text',
send_at: new Date().toISOString(),
is_streaming: true,
chunk_type: 'thinking',
};
return [...msgs, newMsg];
});
} else if (chunk.chunk_type === 'answer') {
// Accumulate answer content (existing behavior)
setStreamingContent((prev) => {
const next = new Map(prev);
const prevContent = next.get(chunk.message_id) ?? '';
const newContent =
prevContent === '' || !chunk.content.startsWith(prevContent)
? chunk.content
: prevContent + chunk.content.slice(prevContent.length);
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;
});
}
// tool_call / tool_result: skip content update entirely — don't pollute display
}
},
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;
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);
const next = {
...prev,
[payload.room_id]: {
...roomMap,
[payload.user_id]: { username: payload.username, avatar_url: payload.avatar_url, timeoutId },
},
};
return next;
});
},
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<RoomContextValue>(
() => ({
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,
streamingThinkingContent,
activeAiStream,
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,
streamingThinkingContent,
activeAiStream,
projectRepos,
reposLoading,
roomAiConfigs,
aiConfigsLoading,
typingUsers,
],
);
return <RoomContext.Provider value={value}>{children}</RoomContext.Provider>;
}
export function useRoom() {
const ctx = useContext(RoomContext);
if (!ctx) throw new Error('useRoom must be used within RoomProvider');
return ctx;
}