import { riskLabel } from "../../i18n/labels"; import { useState, type ReactNode } from "react"; import { Button, Icon, Pill } from "../../shared/ui"; import type { ApprovalRecord, ChatAttachmentRecord, ChatMentionRecord, ChatMessageRecord, ChatSlashInvocationRecord, FileObjectRecord, } from "../../../../shared/domain"; import { ChatMessageRow } from "./ChatMessageRow"; import type { ThreadLink } from "./thread-links"; import type { ChatReplyStatus } from "../../../../shared/chat-domain"; import { formatDay, type MessageContext } from "./chat-helpers"; import { t } from "../../i18n/i18n"; const WINDOW_SIZE = 150; const EMPTY_ATTACHMENTS: ChatAttachmentRecord[] = []; const EMPTY_MENTIONS: ChatMentionRecord[] = []; const EMPTY_INVOCATIONS: ChatSlashInvocationRecord[] = []; function groupBy(items: T[], keyOf: (item: T) => string): Map { const groups = new Map(); for (const item of items) { const key = keyOf(item); const group = groups.get(key); if (group) group.push(item); else groups.set(key, [item]); } return groups; } export function ChatTimeline({ messagesInSpace, pendingApprovals, attachments, mentions, slashInvocations, files, context, currentEmployeeId, historyStart, historyError, onLoadOlder, threadLinks, replyStatuses, onOpenThread, onApprove, onDecline, onEditMessage, onDeleteMessage, onOpenFile, }: { messagesInSpace: ChatMessageRecord[]; pendingApprovals: ApprovalRecord[]; attachments: ChatAttachmentRecord[]; mentions: ChatMentionRecord[]; slashInvocations: ChatSlashInvocationRecord[]; files: FileObjectRecord[]; context: MessageContext; currentEmployeeId: string; /** True once the database said no older message exists. */ historyStart: boolean; historyError: string | null; onLoadOlder: () => void; threadLinks: Map; replyStatuses: ChatReplyStatus[]; onOpenThread: (threadId: string) => void; onApprove: (approvalId: string) => void; onDecline: (approvalId: string) => void; onEditMessage: (messageId: string, bodyMd: string) => Promise; onDeleteMessage: (messageId: string) => void; onOpenFile: (fileId: string) => void; }) { const [visibleCount, setVisibleCount] = useState(WINDOW_SIZE); const attachmentsByMessage = groupBy(attachments, (item) => item.messageId); const repliesByMessage = groupBy(replyStatuses, (item) => item.messageId); const mentionsByMessage = groupBy(mentions, (item) => item.messageId); const invocationsByMessage = groupBy(slashInvocations, (item) => item.messageId); const hiddenCount = Math.max(0, messagesInSpace.length - visibleCount); const visible = hiddenCount > 0 ? messagesInSpace.slice(hiddenCount) : messagesInSpace; const elements: ReactNode[] = []; if (historyError) elements.push(
{t("Earlier messages could not load: {reason}", { reason: historyError })}
); if (hiddenCount > 0 || !historyStart) { // Showing earlier first reveals loaded messages, then asks the database for the next page. const showEarlier = () => { setVisibleCount((count) => count + WINDOW_SIZE); if (hiddenCount === 0) onLoadOlder(); }; elements.push(
, ); } else { elements.push(
{t("This is the start of the conversation.")}
); } let lastDay = ""; for (const message of visible) { const day = formatDay(message.createdAt); if (day !== lastDay) { lastDay = day; elements.push(); } const threadId = message.threadId; elements.push( onOpenThread(threadId) : undefined} onEdit={(bodyMd) => onEditMessage(message.id, bodyMd)} onDelete={() => onDeleteMessage(message.id)} onOpenFile={onOpenFile} />, ); } for (const approval of pendingApprovals) { elements.push( onApprove(approval.id)} onDecline={() => onDecline(approval.id)} />, ); } return <>{elements}; } function DayDivider({ label }: { label: string }) { return (
{label}
); } function ApprovalInline({ approval, onApprove, onDecline }: { approval: ApprovalRecord; onApprove: () => void; onDecline: () => void }) { return (
{t("Approval needed")} {riskLabel(approval.risk)}
{approval.title}
{approval.details}
); }