import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useV2Api } from './useV2Api'; import { V2Pod, V2PodMember } from './useV2Pods'; import { useSocket } from '../../context/SocketContext'; import { useAuth } from '../../context/AuthContext'; /** One button under a message whose payload is `kind: 'starts'`. */ export interface V2Start { id: string; label: string; href?: string; action?: string; // Present only on Scout's server-composed first-task proposal. title?: string; sourceRef?: string; // The first read omits caps; the thread shows them after the hosted start is live. caps?: { agentsPerUser: number; turnsPerDay: number }; } export interface V2Message { id: string; pod_id: string; user_id: string; content: string; message_type: string; author_kind?: 'kernel' | null; created_at: string; createdAt?: string; messageType?: string; user?: { username: string; profile_picture?: string | null; // Face-vs-robot avatar tier. The PG SELECT always fetched u.is_bot; the // backend mapper now forwards it instead of dropping it. isBot?: boolean; }; userId?: string | { _id?: string; username?: string; profilePicture?: string | null; isBot?: boolean; }; // Reply threading (PG reply_to_message_id). POST/GET responses carry either // the normalized `replyTo` object or the raw reply_* columns; the bubble // renders whichever is present. replyTo?: { id: string; content: string; username: string; userId?: string } | null; // Threading (PG thread_root_id, #1106). Present on every message the PG // reads return; null on a root and on anything predating the backfill. // Distinct from reply_to_message_id ON PURPOSE — that one is ADDRESSING and // wakes the parent's author, this one is membership and wakes nobody. thread_root_id?: number | string | null; reply_msg_id?: string | null; reply_content?: string | null; reply_username?: string | null; // Sender-only delivery feedback. Present on POST responses for regular // pods; omitted for DM-shaped pods and older backends. agentDelivery?: { enqueued: number; implicit: string[]; agentsInPod: number; // Wake-on-message targets (#914). Optional: older backends omit it. woken?: number; // Who was woken; older backends omit it. wokenAgents?: string[]; }; // Sender-only feedback from the POST response. Socket broadcasts and // history omit it, so other pod members never see the notice. mentionNotices?: Array<{ code: 'MENTION_NOT_IN_POD'; handle: string; message: string; pods?: Array<{ id: string; name: string }>; }>; // Sprint B5: per-message reactions, aggregated server-side. // Each entry is `{emoji, count, mine, users?}` — `users` is the // Google-Chat-style attribution list decorated by // backend/services/reactionAttributionService (resolves bot // displayName via agentIdentityService.resolveAgentDisplayLabel). // Absent in legacy fixtures + on Mongo fallback; bubbles fall back // to the token-parsed `[[reactions:...]]` shape if so. Older servers // omit `users` entirely — tooltips degrade to count-only. reactions?: Array<{ emoji: string; count: number; mine: boolean; users?: Array<{ id: string; username: string; displayName?: string }>; }>; // ADR-020 D3: structured component payload. `kind` discriminates renderers // (approval-card and byo-handoff). Server-composed; survives normalizeMessage via // the `...raw` spread — declared here so consumers can read it typed. payload?: { runId?: string; runPodId?: string; kind?: string; action?: string; actorName?: string; taskId?: string; elements?: unknown[]; v?: number; lang?: string; source?: string; catalogId?: string; catalogRev?: number; catalogHash?: string; // kind 'starts': offers rendered as buttons under the message (first // session, direction B). A plain start sends its label as the person's own // message; `href` navigates in-app, and supported actions act in place. starts?: V2Start[]; approvalId?: string; actionType?: string; summary?: string; params?: Record; status?: string; decision?: string; ownerUserId?: string; agentName?: string; expiresAt?: string; executionResult?: { podId?: string; podName?: string } | Record; executionError?: string; // kind 'byo-handoff': server-composed, token-free commands for a local // runtime. The card copies commands[os] verbatim; it never rebuilds or // interpolates a shell command in the browser. adapter?: 'claude' | 'codex' | 'opencode' | 'dsh' | string; podId?: string; os?: 'mac' | 'linux' | 'windows' | string; commands?: Partial>; supportedOs?: Array<'mac' | 'linux' | 'windows' | string>; toolCallEnding?: 'ran' | 'failed' | 'refused' | 'declined' | 'expired'; toolCallCode?: string; toolCallTrailId?: string; // Raw tool-call arguments are intentionally absent from the shared card // payload. The owner fetches this field from /api/approvals/pending while // the action is flagged; the backend scopes it to ownerUserId. toolCall?: { grantId?: string; callId?: string; tool?: string; canonicalArgs?: Record; argsDigest?: string; }; } | null; } export interface V2Agent { agentName: string; userId?: string; profilePicture?: string | null; instanceId?: string; displayName?: string; iconUrl?: string; status?: string; lastHeartbeatAt?: string | null; // Role family — resolved server-side from the agent's preset // (Development, Design, Strategy, Marketing, Research, etc.). Renders // as a small chip in the inspector and powers the Your Team filters. // Null for agents installed without a known preset. category?: string | null; runtime?: { // Identity-bearing runtime tag — codex / claude-code / openclaw / // moltbot / webhook / internal / local-cli (legacy). runtimeType?: string; // 'cloud' (Commonly-managed) or 'byo' (user runs the agent themselves // — ADR-005 local CLI, future user-server, etc.). Cloud is the default // for built-ins. Server-side normalization fills this from // sanitizeRuntimeConfig — frontend can trust either field's presence. host?: 'cloud' | 'byo'; // Legacy CLI shape (pre-2026-05-04). Backend normalizes to runtimeType // + host:'byo' on read; kept here for defense-in-depth in case an // older backend serves an unmigrated payload. wrappedCli?: string; } | null; profile?: { purpose?: string; persona?: { tone?: string; specialties?: string[] }; avatarUrl?: string; iconUrl?: string; displayName?: string; }; } interface AgentsResponse { agents: V2Agent[]; } interface MessagesResponse { // The API returns either a normalized array or a Mongo array; we type it loosely. data?: V2Message[]; } export type LoadOlderResult = 'noop' | 'prepended' | 'unchanged' | 'empty' | 'failed' | 'stale'; export interface UseV2PodDetailResult { pod: V2Pod | null; members: V2PodMember[]; messages: V2Message[]; agents: V2Agent[]; loading: boolean; /** True after the current pod's initial pod/message read has settled. */ initialLoadComplete?: boolean; /** The current pod has returned a successful initial messages page, including an empty page. */ initialMessagesLoaded?: boolean; error: string | null; /** Send failures belong beside the composer; load failures use `error`. */ sendError: string | null; /** A full page came back, so older history probably exists. */ hasMore: boolean; loadingOlder: boolean; loadOlder: () => Promise; /** Bounded state for an automatic quote/activity lookup in older history. */ historySearch?: HistorySearchState; searchOlderForMessage?: (messageId: string | number, retry?: boolean) => Promise; retryHistorySearch?: () => Promise; refresh: () => Promise; // `threadRootId` posts INTO a thread without addressing anyone: the backend // takes it as membership and leaves reply_to null, so joining a thread does // not ping the root's author (@ux-lead 56879, resolver in #1128). // // Passing both is a caller bug, but NOT one the backend catches: the // resolver 400s the pair only when they DISAGREE, and returns happily when // a reply edge and an explicit root point at the same thread. Keeping them // exclusive is therefore the client's job and nothing downstream will // notice if it stops. sendMessage: ( content: string, messageType?: string, replyToMessageId?: string, threadRootId?: string, ) => Promise; } export interface HistorySearchState { targetId: string | null; status: 'idle' | 'searching' | 'failed' | 'not-found'; attempt: number; maxAttempts: number; error: string | null; } const REQUEST_TIMEOUT_MS = 8000; const SEND_TIMEOUT_MS = 20000; const RECONNECT_ERROR = 'Messages could not be refreshed after reconnect. Try reloading this pod.'; // One page of history. The backend clamps `limit` to [1,200]; 50 matches the // showcase reader so both surfaces page at the same rate. const PAGE_SIZE = 50; export const MAX_HISTORY_SEARCH_PAGES = 5; // Merge two message lists by id, newest-last. Used when prepending a page of // history: a plain concat would duplicate anything the socket delivered while // the request was in flight. const mergeMessagesById = (a: V2Message[], b: V2Message[]): V2Message[] => { const byId = new Map(); for (const m of a) byId.set(String(m.id), m); for (const m of b) byId.set(String(m.id), m); return chronologicalMessages([...byId.values()]); }; const normalizeMessage = (raw: V2Message): V2Message => { const rawUserId = raw.userId; const userObject = typeof rawUserId === 'object' && rawUserId !== null ? rawUserId : null; const username = raw.user?.username || userObject?.username || 'Unknown'; const profilePicture = raw.user?.profile_picture || userObject?.profilePicture || null; const isBot = raw.user?.isBot ?? userObject?.isBot; return { ...raw, id: raw.id || (raw as { _id?: string })._id || '', pod_id: raw.pod_id || (raw as { podId?: string }).podId || '', user_id: raw.user_id || (typeof rawUserId === 'string' ? rawUserId : userObject?._id) || '', message_type: raw.message_type || raw.messageType || 'text', created_at: raw.created_at || raw.createdAt || new Date().toISOString(), content: raw.content || (raw as { text?: string }).text || '', user: { username, profile_picture: profilePicture, // Absent on old cached payloads and some socket shapes — undefined means // "unknown", and V2Avatar renders the neutral tier for unknown rather // than guessing a species. ...(isBot === undefined ? {} : { isBot }), }, }; }; const chronologicalMessages = (messages: V2Message[]): V2Message[] => ( [...messages].sort((a, b) => ( new Date(a.created_at || a.createdAt || 0).getTime() - new Date(b.created_at || b.createdAt || 0).getTime() )) ); // `emitReactionChange` computes `mine` for the user who triggered the change and // broadcasts that single view to the whole room — so every OTHER client gets a // wrong `mine`, which flips the add/remove toggle (clicking your own reaction // re-adds instead of removing) and mis-highlights chips (2026-07-24). Recompute // `mine` for THIS client from each reaction's user list; fall back to the wire // value only when `users` is absent (older server / Mongo fallback path). export const recomputeReactionMine = }>( reactions: T[], userId: string | undefined | null, ): T[] => reactions.map((r) => ({ ...r, mine: Array.isArray(r.users) && userId ? r.users.some((u) => String(u.id) === String(userId)) : r.mine, })); export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => { const api = useV2Api(); const { socket, connected, joinPod, leavePod } = useSocket(); const { currentUser } = useAuth(); const [pod, setPod] = useState(null); const [messages, setMessages] = useState([]); const [agents, setAgents] = useState([]); const [loading, setLoading] = useState(false); // `loading: false` is also the first-render default. Keep that transient // state distinct from a settled empty page so landing cannot declare a // target absent before the initial messages request has even started. const [initialLoadComplete, setInitialLoadComplete] = useState(false); const [initialMessagesLoaded, setInitialMessagesLoaded] = useState(false); const [error, setError] = useState(null); const [sendError, setSendError] = useState(null); const [hasMore, setHasMore] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); const [historySearch, setHistorySearch] = useState({ targetId: null, status: 'idle', attempt: 0, maxAttempts: MAX_HISTORY_SEARCH_PAGES, error: null, }); // A request for the room we just left can resolve after the next room has // started loading. Keep both the pre-paint reset and every async write bound // to the currently selected pod, so no previous room's messages can flash // under a new DM header. const activePodIdRef = useRef(podId); const connectionRef = useRef({ podId: null as string | null, wasConnected: false, hasConnected: false }); const refreshSequenceRef = useRef(0); const messagesRef = useRef([]); const hasMoreRef = useRef(false); const loadingOlderRef = useRef(false); const historySearchRef = useRef(historySearch); const historySearchSequenceRef = useRef(0); const updateHistorySearch = useCallback((next: HistorySearchState) => { historySearchRef.current = next; setHistorySearch(next); }, []); useLayoutEffect(() => { activePodIdRef.current = podId; refreshSequenceRef.current += 1; historySearchSequenceRef.current += 1; messagesRef.current = []; hasMoreRef.current = false; loadingOlderRef.current = false; setPod(null); setMessages([]); setAgents([]); setError(null); setSendError(null); setInitialLoadComplete(false); setInitialMessagesLoaded(false); setHasMore(false); setLoadingOlder(false); updateHistorySearch({ targetId: null, status: 'idle', attempt: 0, maxAttempts: MAX_HISTORY_SEARCH_PAGES, error: null, }); }, [podId, updateHistorySearch]); const fetchPod = useCallback(async (id: string) => { const result = await api.get(`/api/pods/${id}`, { timeout: REQUEST_TIMEOUT_MS }); if (activePodIdRef.current === id) setPod(result); }, [api]); const fetchMessages = useCallback(async (id: string, reconnect = false) => { try { const data = await api.get( `/api/messages/${id}?limit=${PAGE_SIZE}`, { timeout: REQUEST_TIMEOUT_MS }, ); const list = Array.isArray(data) ? data : []; if (activePodIdRef.current !== id) return; const normalized = chronologicalMessages(list.map(normalizeMessage)); // A catch-up page cannot establish whether earlier history exists. if (!reconnect) setInitialMessagesLoaded(true); if (reconnect) { // A socket event (or a previously loaded history page) can arrive // while the catch-up request is in flight. Keep those rows and dedupe. setMessages((previous) => { if (activePodIdRef.current !== id) return previous; const merged = mergeMessagesById(normalized, previous); messagesRef.current = merged; return merged; }); } else { messagesRef.current = normalized; setMessages(normalized); } // This endpoint returns a bare array with no envelope, so end-of-history // is inferred: a short page means there is nothing older behind it. // A reconnect page starts at the newest message; its length cannot say // whether older history remains behind the already loaded pages. if (!reconnect) { hasMoreRef.current = list.length >= PAGE_SIZE; setHasMore(hasMoreRef.current); } } catch (err) { const e = err as { response?: { status?: number } }; if (reconnect) throw err; if (e.response?.status === 404 && activePodIdRef.current === id) { messagesRef.current = []; hasMoreRef.current = false; setMessages([]); setHasMore(false); } else throw err; } }, [api]); /** * Prepend one page of older history. * * Anchors on the oldest message currently held and asks for everything * strictly before it (`before` is already supported server-side and is the * same cursor the showcase reader uses). Results are merged by id rather * than concatenated, because the socket may have delivered messages while * the request was in flight. */ const fetchOlderPage = useCallback(async (id: string, cursor: string): Promise => { const data = await api.get( `/api/messages/${id}?limit=${PAGE_SIZE}&before=${encodeURIComponent(cursor)}`, { timeout: REQUEST_TIMEOUT_MS }, ); return (Array.isArray(data) ? data : []).map(normalizeMessage); }, [api]); const loadOlder = useCallback(async (): Promise => { if (!podId || loadingOlderRef.current) return 'noop'; const oldest = messagesRef.current[0]; if (!oldest) return 'noop'; const cursor = oldest.created_at || oldest.createdAt; if (!cursor) return 'noop'; loadingOlderRef.current = true; setLoadingOlder(true); try { const older = await fetchOlderPage(podId, cursor); if (activePodIdRef.current !== podId) return 'stale'; if (older.length === 0) { hasMoreRef.current = false; setHasMore(false); return 'empty'; } if (activePodIdRef.current === podId) { const beforeFirstId = messagesRef.current[0]?.id; const merged = mergeMessagesById(older, messagesRef.current); messagesRef.current = merged; hasMoreRef.current = older.length >= PAGE_SIZE; setMessages(merged); setHasMore(hasMoreRef.current); return merged[0]?.id !== beforeFirstId ? 'prepended' : 'unchanged'; } return 'stale'; } catch { // Leave hasMore alone so the same button can be retried. return 'failed'; } finally { if (activePodIdRef.current === podId) { loadingOlderRef.current = false; setLoadingOlder(false); } } }, [fetchOlderPage, podId]); /** * Find a quoted/activity source without turning a missing row into an * unbounded request loop. Automatic lookup gets at most five pages. A * failed lookup is terminal until the reader deliberately presses Retry. */ const searchOlderForMessage = useCallback(async (messageId: string | number, retry = false): Promise => { if (!podId) return; const targetId = String(messageId); const existing = historySearchRef.current; if (messagesRef.current.some((message) => String(message.id) === targetId)) return; if (loadingOlderRef.current) return; if (existing.targetId === targetId && existing.status === 'searching') return; if (!retry && existing.targetId === targetId && (existing.status === 'failed' || existing.status === 'not-found')) return; const searchSequence = ++historySearchSequenceRef.current; let currentMessages = messagesRef.current; let canLoadMore = hasMoreRef.current; updateHistorySearch({ targetId, status: 'searching', attempt: 0, maxAttempts: MAX_HISTORY_SEARCH_PAGES, error: null, }); loadingOlderRef.current = true; setLoadingOlder(true); const setTerminal = (status: HistorySearchState['status'], error: string | null = null) => { if (searchSequence !== historySearchSequenceRef.current) return; updateHistorySearch({ targetId, status, attempt: historySearchRef.current.attempt, maxAttempts: MAX_HISTORY_SEARCH_PAGES, error, }); }; try { for (let attempt = 1; attempt <= MAX_HISTORY_SEARCH_PAGES; attempt += 1) { if (searchSequence !== historySearchSequenceRef.current || activePodIdRef.current !== podId) return; if (currentMessages.some((message) => String(message.id) === targetId)) { setTerminal('idle'); return; } const oldest = currentMessages[0]; const cursor = oldest?.created_at || oldest?.createdAt; if (!canLoadMore || !cursor) { setTerminal('not-found'); return; } updateHistorySearch({ targetId, status: 'searching', attempt, maxAttempts: MAX_HISTORY_SEARCH_PAGES, error: null, }); const older = await fetchOlderPage(podId, cursor); if (searchSequence !== historySearchSequenceRef.current || activePodIdRef.current !== podId) return; if (older.length === 0) { canLoadMore = false; hasMoreRef.current = false; setHasMore(false); setTerminal('not-found'); return; } // Refresh from the ref before merging: a socket message may have // arrived while this page was in flight, and must never be lost when // the older page is prepended. currentMessages = mergeMessagesById(older, messagesRef.current); messagesRef.current = currentMessages; canLoadMore = older.length >= PAGE_SIZE; hasMoreRef.current = canLoadMore; setMessages(currentMessages); setHasMore(canLoadMore); if (currentMessages.some((message) => String(message.id) === targetId)) { setTerminal('idle'); return; } if (!canLoadMore) { setTerminal('not-found'); return; } } setTerminal('not-found'); } catch (err) { const reason = err instanceof Error ? err.message : 'Unable to load older history'; setTerminal('failed', reason); } finally { if (searchSequence === historySearchSequenceRef.current) { loadingOlderRef.current = false; setLoadingOlder(false); } } }, [fetchOlderPage, podId, updateHistorySearch]); const retryHistorySearch = useCallback(async () => { const targetId = historySearchRef.current.targetId; if (targetId) await searchOlderForMessage(targetId, true); }, [searchOlderForMessage]); const fetchAgents = useCallback(async (id: string) => { try { const data = await api.get(`/api/registry/pods/${id}/agents`, { timeout: REQUEST_TIMEOUT_MS }); // Registry returns each agent with `name` (e.g. "openclaw"), but the // V2Agent contract is `agentName`. Normalize at the boundary so every // downstream consumer (including the "Talk to" POST body) gets a // populated agentName instead of silently sending undefined. const raw = Array.isArray(data?.agents) ? data.agents : []; const normalized = raw.map((a: V2Agent & { name?: string }) => ({ ...a, agentName: a.agentName || a.name || '', })); if (activePodIdRef.current === id) setAgents(normalized); } catch { if (activePodIdRef.current === id) setAgents([]); } }, [api]); const refresh = useCallback(async () => { const refreshSequence = ++refreshSequenceRef.current; historySearchSequenceRef.current += 1; historySearchRef.current = { targetId: null, status: 'idle', attempt: 0, maxAttempts: MAX_HISTORY_SEARCH_PAGES, error: null, }; setHistorySearch(historySearchRef.current); messagesRef.current = []; hasMoreRef.current = false; loadingOlderRef.current = false; setLoadingOlder(false); setInitialLoadComplete(false); setInitialMessagesLoaded(false); if (!podId) { setPod(null); setMessages([]); setAgents([]); setSendError(null); setInitialLoadComplete(true); return; } setLoading(true); setError(null); setSendError(null); try { await fetchPod(podId); const [messagesResult] = await Promise.allSettled([ fetchMessages(podId), fetchAgents(podId), ]); if (refreshSequence !== refreshSequenceRef.current || activePodIdRef.current !== podId) return; if (messagesResult.status === 'rejected') { setMessages([]); const e = messagesResult.reason as { response?: { status?: number; data?: { error?: string; msg?: string } }; message?: string }; const status = e.response?.status; if (status === 401 || status === 403) { // Friendlier soft state — backend's raw "Not authorized to view // messages in this pod" reads like a security warning. For a // non-member browsing a shared pod link, "You're not a member" // is the truer framing. setError("You're not a member of this pod, so messages aren't visible."); } else { setError(e.response?.data?.error || e.response?.data?.msg || e.message || 'Messages are taking too long to load'); } } } catch (err) { if (refreshSequence !== refreshSequenceRef.current || activePodIdRef.current !== podId) return; const e = err as { response?: { status?: number; data?: { error?: string; msg?: string } }; message?: string }; const status = e.response?.status; if (status === 401 || status === 403) { setError("You're not a member of this pod."); } else { setError(e.response?.data?.error || e.response?.data?.msg || e.message || 'Failed to load pod'); } } finally { if (refreshSequence === refreshSequenceRef.current && activePodIdRef.current === podId) { setLoading(false); setInitialLoadComplete(true); } } }, [podId, fetchPod, fetchMessages, fetchAgents]); useEffect(() => { refresh(); }, [refresh]); useEffect(() => { const state = connectionRef.current; if (state.podId !== podId) { connectionRef.current = { podId, wasConnected: connected, hasConnected: connected }; return; } const reconnected = connected && !state.wasConnected && state.hasConnected; state.wasConnected = connected; if (connected) state.hasConnected = true; if (!reconnected || !podId) return; fetchMessages(podId, true).then(() => { if (activePodIdRef.current === podId) { setError((current) => current === RECONNECT_ERROR ? null : current); } }).catch(() => { if (activePodIdRef.current === podId) { setError(RECONNECT_ERROR); } }); }, [podId, connected, fetchMessages]); useEffect(() => { if (!podId || !socket || !connected) return undefined; joinPod(podId); const handleNewMessage = (raw: V2Message) => { const normalized = normalizeMessage(raw); if (normalized.pod_id && normalized.pod_id !== podId) return; setMessages((prev) => { if (prev.some((m) => m.id && m.id === normalized.id)) return prev; const next = chronologicalMessages([...prev, normalized]); messagesRef.current = next; return next; }); }; // Sprint B5: socket-driven reaction updates. Backend emits // `messageReaction` with the new aggregated list after every add/remove // by any user. We patch only the matching message's `reactions` array // — no other state churn. const handleReactionChange = (payload: { messageId: string; podId?: string; reactions: Array<{ emoji: string; count: number; mine: boolean; users?: Array<{ id: string; username: string; displayName?: string }> }> }) => { if (!payload || !payload.messageId) return; if (payload.podId && payload.podId !== podId) return; // `emitReactionChange` computes `mine` for the user who triggered the // change and broadcasts that single view to the whole room. Trusting it // gives every OTHER client a wrong `mine` — which flips the add/remove // toggle (clicking your own reaction re-adds instead of removing) and // mis-highlights chips. Recompute `mine` for THIS client from the // reaction's user list (2026-07-24). Fall back to the wire value only // when `users` is absent (older server / Mongo fallback). const reactions = recomputeReactionMine(payload.reactions, currentUser?._id); setMessages((prev) => prev.map((m) => ( String(m.id) === String(payload.messageId) ? { ...m, reactions } : m ))); }; // ADR-020 D3: card status transitions. Same patch discipline as // reactions — one field on one message; the server's payload is // authoritative (card faces are shared state, never per-viewer). const handleCardUpdate = (update: { messageId: string; podId?: string; payload?: V2Message['payload'] }) => { if (!update || !update.messageId) return; if (update.podId && update.podId !== podId) return; setMessages((prev) => prev.map((m) => ( String(m.id) === String(update.messageId) ? { ...m, payload: update.payload } : m ))); }; socket.on('newMessage', handleNewMessage); socket.on('messageReaction', handleReactionChange); socket.on('messageCardUpdated', handleCardUpdate); return () => { socket.off('newMessage', handleNewMessage); socket.off('messageReaction', handleReactionChange); socket.off('messageCardUpdated', handleCardUpdate); leavePod(podId); }; }, [podId, socket, connected, joinPod, leavePod, currentUser?._id]); const sendMessage = useCallback(async ( content: string, messageType = 'text', replyToMessageId?: string, threadRootId?: string, ): Promise => { if (!podId || !content.trim()) return null; try { setSendError(null); const created = await api.post( `/api/messages/${podId}`, { content: content.trim(), messageType, ...(replyToMessageId ? { replyToMessageId } : {}), ...(threadRootId ? { threadRootId } : {}), }, { timeout: SEND_TIMEOUT_MS }, ); // A send started in a pod we have already left must not append its late // response into the newly selected pod's transcript. if (activePodIdRef.current !== podId) return null; const normalized = normalizeMessage(created); // Dedupe by id — the Socket.io `newMessage` broadcast and this // optimistic add both come from the same DB row and race after // backend PR #304 (which added the user-message broadcast). Whichever // arrives first wins; the second is a no-op. Without this, sending // a message renders it twice in the sender's tab. When the socket copy // won the race, still graft this POST copy's reply fields onto it — // an older backend's broadcast omits replyTo, which dropped the reply // quote until reload (#646). setMessages((prev) => { if (activePodIdRef.current !== podId) return prev; if (prev.some((m) => m.id && m.id === normalized.id)) { const next = prev.map((m) => ( m.id === normalized.id ? { ...m, replyTo: m.replyTo ?? normalized.replyTo ?? null, // #646's graft, one field over: when the socket copy wins // the race and lacks the thread root (older server), keep // the POST copy's — or the sender's own in-thread reply // renders top-level until refresh (2026-08-24). thread_root_id: m.thread_root_id ?? normalized.thread_root_id ?? null, mentionNotices: normalized.mentionNotices ?? m.mentionNotices, } : m )); messagesRef.current = next; return next; } const next = chronologicalMessages([...prev, normalized]); messagesRef.current = next; return next; }); if (activePodIdRef.current !== podId) return null; return normalized; } catch (err) { if (activePodIdRef.current !== podId) return null; const e = err as { response?: { data?: { error?: string; msg?: string } }; message?: string }; setSendError(e.response?.data?.error || e.response?.data?.msg || e.message || 'Failed to send message'); return null; } }, [api, podId]); const members: V2PodMember[] = (pod?.members || []).filter( (m): m is V2PodMember => typeof m === 'object' && m !== null, ); return { pod, members, messages, agents, loading, initialLoadComplete, initialMessagesLoaded, error, sendError, hasMore, loadingOlder, loadOlder, historySearch, searchOlderForMessage, retryHistorySearch, refresh, sendMessage, }; }; // Suppress unused import warning when MessagesResponse is not used below. export type { MessagesResponse };