import React, { useCallback, useEffect, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { HistorySearchState, V2Message, V2Start } from '../hooks/useV2PodDetail'; import { UseV2ThreadState } from '../hooks/useV2ThreadState'; import { WELCOME_HOSTED_ACTION, type WelcomeHostedStartState } from '../hooks/useWelcomeHostedStart'; import { ThreadViewItem } from '../utils/threadView'; import { isGroupedWithPrevious } from '../utils/messageGrouping'; import V2MessageRow from './V2MessageRow'; import V2ThreadCard from './V2ThreadCard'; import { V2DecisionCardData, V2DecisionRuling } from './V2DecisionCard'; // Expanded threads show the newest eight; a `N more replies` line reveals the rest. const MAX_EXPANDED_REPLIES = 8; interface V2ThreadMessagesProps { messages: V2Message[]; threadView: ThreadViewItem[]; threadState: Pick & Partial>; decisionByMessageId: Map; settledDecisionByMessageId: Map; agentDisplayNames: Map; agentTags?: Map; agentAuthorKeys: Set; onAuthorClick?: (author: string) => void; onOpenFile?: (fileName: string) => void; onReply?: (message: V2Message) => void; onThread?: (message: V2Message) => void; // Scout's starts, keyed by the message that offers them; already filtered to // the ones still available to this person (V2Thread). startsByMessageId?: Map; onStart?: (start: V2Start, messageId: string) => void; startsDisabled?: boolean; // The welcome's hosted start while it runs in place, or after it failed. hostedStart?: WelcomeHostedStartState; onQuoteNavigate?: (messageId: string | number) => void; onDecisionRuled?: (decisionId: string, ruling: V2DecisionRuling) => void; onAimAtThread: (rootId: string, preview: string) => void; hasMore: boolean; loadingOlder: boolean; onLoadOlder: () => void; // Direction C history edge: the sentinel V2Thread observes to auto-load the // previous page when the reader reaches the top. edgeRef?: React.RefObject; // Jump-to-latest pill: messages that arrived while the reader was scrolled up. jumpCount?: number; // Mount the pill once the reader is a viewport up, even before arrivals. showJump?: boolean; onJump?: () => void; /** * A message id the reader is landing on (quote link, Activity) that is not * in the DOM because its thread is collapsed or folded behind `N more * replies`. The transcript opens that thread in full and reports back, so * the landing effect can re-run — a reveal, never a history fetch * (sprint-review 64477). */ revealMessageId?: string | null; onRevealed?: (messageId: string, found: boolean) => void; loading: boolean; error: string | null; starterPanel?: React.ReactNode; emptyState?: React.ReactNode; afterMessage?: { messageId: string; content: React.ReactNode }; agentDeliveryHint?: { messageId: string; mentionHandle: string } | null; // A pod with no installed agent answers nothing by construction, so there is // no handle to suggest — a different row, in the same slot. noAgentsHint?: { messageId: string } | null; onAddAgent?: () => void; messagesContainerRef: React.RefObject; messagesEndRef: React.RefObject; } /** * The transcript is intentionally separate from V2Thread's transport and * composer state. It owns only read order: flat rows, reply rails, and the * one durable decision card that can replace a request message in either. */ export const V2ThreadHistoryStatus: React.FC<{ historySearch: HistorySearchState; onRetryHistorySearch?: () => void; viewport?: boolean; }> = ({ historySearch, onRetryHistorySearch, viewport = false }) => { const { t } = useTranslation(); const className = `v2-thread__history-status${viewport ? ' v2-thread__history-status--viewport' : ''}${historySearch.status === 'failed' ? ' v2-thread__history-status--error' : ''}`; if (historySearch.status === 'searching') { return (
{t('podChat.history.searchingOlder', { attempt: historySearch.attempt, max: historySearch.maxAttempts, })}
); } if (historySearch.status === 'failed') { return (
{t('podChat.history.searchOlderFailed')} {onRetryHistorySearch && ( )}
); } if (historySearch.status === 'not-found') { return (
{t('podChat.history.searchOlderBound', { attempt: historySearch.attempt })} {onRetryHistorySearch && ( )}
); } return null; }; const V2ThreadMessages: React.FC = ({ messages, threadView, threadState, decisionByMessageId, settledDecisionByMessageId, agentDisplayNames, agentTags, agentAuthorKeys, onAuthorClick, onOpenFile, onReply, onThread, startsByMessageId, onStart, startsDisabled = false, hostedStart = null, onQuoteNavigate, onDecisionRuled, onAimAtThread, hasMore, loadingOlder, onLoadOlder, edgeRef, jumpCount = 0, showJump = false, onJump, revealMessageId, onRevealed, loading, error, starterPanel, emptyState, afterMessage, agentDeliveryHint, noAgentsHint, onAddAgent, messagesContainerRef, messagesEndRef, }) => { const { t } = useTranslation(); // Expanded threads show the newest MAX_EXPANDED_REPLIES; "N more replies" // reveals the rest for that root (walk-3 §3). const [expandedAll, setExpandedAll] = useState>(() => new Set()); // The resting state of a thread is the chip (ux-lead 64476 (4)). Which // roots are open is a session gesture held here; the server `collapsed` // row is written to match on each open/close (it still drives the wake // path), but a fresh mount always shows chips. const [openRoots, setOpenRoots] = useState>(() => new Set()); const setOpen = useCallback((rootId: string, open: boolean) => { setOpenRoots((current) => { const next = new Set(current); if (open) next.add(rootId); else next.delete(rootId); return next; }); threadState.setCollapsed?.(rootId, !open); }, [threadState]); // Landing on a folded reply: open its thread, drop the `N more replies` // fold, then tell the caller so it can land on the now-rendered row. useEffect(() => { if (!revealMessageId) return; const target = String(revealMessageId); const owner = threadView.find((item) => item.kind === 'card' && (item.rootId === target || item.replies.some((reply) => String(reply.id) === target))); if (owner && owner.kind === 'card') { setOpenRoots((current) => (current.has(owner.rootId) ? current : new Set(current).add(owner.rootId))); setExpandedAll((current) => (current.has(owner.rootId) ? current : new Set(current).add(owner.rootId))); } onRevealed?.(target, Boolean(owner)); }, [revealMessageId, threadView, onRevealed]); const rootPreview = (rootId: string): string => String( messages.find((message) => String(message.id) === rootId)?.content || '', ); const rulingMessage = (source: V2Message, ruling: V2DecisionRuling): V2Message => { // `chooseDecision` creates an ordinary PG reply before acknowledging the // choice, so a socket-connected view normally already has this durable // row. Reuse it for valid reactions, timestamps, and identity; the small // fallback only covers the short response-before-socket race. const durable = ruling.messageId ? messages.find((message) => String(message.id) === String(ruling.messageId)) : undefined; if (durable) return durable; return { ...source, id: `decision-ruling-${source.id}`, user_id: 'decision-ruling', content: ruling.value, created_at: ruling.at || source.created_at, createdAt: ruling.at || source.createdAt, user: { username: ruling.by || t('common.you'), isBot: false }, replyTo: null, reply_msg_id: null, reply_content: null, reply_username: null, }; }; // A settled ruling keeps the ordinary reply id it created when the choice // was acknowledged. When that source request is rendered inside an // expanded thread, replace the request with the ruling and omit the // durable reply row so the answer appears once with the ruled marker. const renderedReplyEntries = (replies: V2Message[]) => { const visibleReplyIds = new Set(replies.map((reply) => String(reply.id))); const settledRulingReplyIds = new Set( Array.from(settledDecisionByMessageId.entries()) .filter(([sourceId, ruling]) => visibleReplyIds.has(String(sourceId)) && ruling.messageId) .map(([, ruling]) => String(ruling.messageId)), ); return replies .filter((reply) => ( !settledRulingReplyIds.has(String(reply.id)) || settledDecisionByMessageId.has(String(reply.id)) )) .map((reply) => { const settledRuling = settledDecisionByMessageId.get(String(reply.id)); return { sourceId: String(reply.id), message: settledRuling ? rulingMessage(reply, settledRuling) : reply, ruling: settledRuling, }; }); }; return (
{/* History edge: one mono line. Loads on scroll (observer in V2Thread); the button form stays reachable by keyboard. */}
0 ? 'beginning' : 'empty'}> {loadingOlder ? ( {t('podChat.history.loadingEarlier')} ) : hasMore ? ( ) : messages.length > 0 ? ( {t('podChat.history.beginning')} ) : null}
{error &&
{error}
} {loading && messages.length === 0 &&
} {starterPanel} {emptyState} {threadView.map((item, index, view) => { const renderMessage = (message: V2Message, previous: ThreadViewItem | undefined) => { // A held agent answers the welcome click with no install, so the // running line must not depend on the start carrying its limits. const liveHosted = hostedStart?.status === 'live' && hostedStart.messageId === String(message.id) ? hostedStart : null; const liveHostedCaps = liveHosted ? message.payload?.starts?.find((start) => start.action === WELCOME_HOSTED_ACTION)?.caps : undefined; const settledRuling = settledDecisionByMessageId.get(String(message.id)); const renderedMessage = settledRuling ? rulingMessage(message, settledRuling) : message; const previousMessage = previous && previous.kind === 'message' ? (() => { const previousRuling = settledDecisionByMessageId.get(String(previous.message.id)); return previousRuling ? rulingMessage(previous.message, previousRuling) : previous.message; })() : undefined; return ( {onStart && (startsByMessageId?.get(String(message.id))?.length ?? 0) > 0 && (
{startsByMessageId?.get(String(message.id))?.map((start) => { const hostedHere = start.action === WELCOME_HOSTED_ACTION && hostedStart?.messageId === String(message.id) ? hostedStart : null; const starting = hostedHere?.status === 'starting'; return ( {hostedHere?.status === 'failed' && (

{hostedHere.error}

)}
); })}
)} {liveHosted && (

{t('agentByo.hosted.running', { name: liveHosted.agentName })} {liveHostedCaps && ( <> {' '} {t('agentByo.compact.limits', { agents: liveHostedCaps.agentsPerUser, turns: liveHostedCaps.turnsPerDay })} )}

)} {afterMessage?.messageId === String(message.id) && afterMessage.content} {agentDeliveryHint?.messageId === message.id && (
}} />
)} {noAgentsHint?.messageId === message.id && (
{t('podChat.noAgentsInPod.kicker')}
{t('podChat.noAgentsInPod.body')}
{onAddAgent && ( )}
)}
); }; if (item.kind === 'message') { const next = view[index + 1]; // A root with a live thread renders INSIDE its band (below), so the // root and its replies share one surface — ux-lead 64476 (2). if (next && next.kind === 'card' && next.rootId === String(item.message.id) && !settledDecisionByMessageId.has(next.rootId)) return null; return renderMessage(item.message, view[index - 1]); } const state = threadState.byRoot.get(item.rootId); // The request source has already transformed into the human's ruling // in the flat transcript above. Its durable reply remains in the // server list, but rendering both would show the same pick twice. if (settledDecisionByMessageId.has(item.rootId)) return null; const rootItem = view[index - 1]; const rootMessage = rootItem && rootItem.kind === 'message' && String(rootItem.message.id) === item.rootId ? rootItem.message : messages.find((message) => String(message.id) === item.rootId); const collapsed = !openRoots.has(item.rootId); const shownReplies = collapsed || expandedAll.has(item.rootId) || item.replies.length <= MAX_EXPANDED_REPLIES ? item.replies : item.replies.slice(item.replies.length - MAX_EXPANDED_REPLIES); const hiddenReplies = item.replies.length - shownReplies.length; const hiddenHasAfter = hiddenReplies > 0 && item.replies.slice(0, hiddenReplies).some((reply) => String(reply.id) === afterMessage?.messageId); const following = state ? state.following : null; const followLabel = following === true ? t('podChat.thread.following') : following === false ? t('podChat.thread.muted') : t('podChat.thread.follow'); return (
{rootMessage && renderMessage(rootMessage, view[index - 2])}
{collapsed && ( setOpen(item.rootId, true)} /> )} {!collapsed && (
{hiddenReplies > 0 && ( )} {hiddenHasAfter && afterMessage?.content} {renderedReplyEntries(shownReplies).map(({ sourceId, message: reply, ruling }, replyIndex, renderedReplies) => ( {afterMessage?.messageId === sourceId && afterMessage.content} ))}
{item.replyCount} {item.replyCount === 1 ? t('podChat.thread.replyOne') : t('podChat.thread.replyOther')} {onReply && ( )}
)}
{collapsed && item.replies.some((reply) => String(reply.id) === afterMessage?.messageId) && afterMessage?.content}
); })}
{onJump && (showJump || jumpCount > 0) && (
)}
); }; export default V2ThreadMessages;