import "./MailboxModal.css"; import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Mail, Send, Inbox as InboxIcon, Bot, Trash2, CheckCheck, Loader2, RefreshCw, MessageSquare, User, } from "lucide-react"; import type { Message, MessageType, ParticipantType } from "@fusion/core"; import { fetchInbox, fetchOutbox, fetchUnreadCount, fetchAgentMailbox, fetchAllAgentMailbox, markMessageRead, markAllMessagesRead, deleteMessage, fetchConversation, fetchAgents, fetchApprovals, fetchApprovalDetail, decideApproval, type InboxResponse, type OutboxResponse, type AgentMailboxResponse, type AllAgentsMailboxResponse, type Agent, type ApprovalRequestSummary, type ApprovalRequestDetail, } from "../api"; import { MailboxMessageContent } from "./MailboxMessageContent"; import { MessageComposer } from "./MessageComposer"; import { ViewHeader } from "./ViewHeader"; import { WorktrunkInstallApprovalDetails } from "./WorktrunkInstallApprovalDetails"; import { subscribeSse } from "../sse-bus"; import { useViewportMode } from "../hooks/useViewportMode"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { getScopedItem, setScopedItem } from "../utils/projectStorage"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; // ── Types ───────────────────────────────────────────────────────────────── type MailboxTab = "inbox" | "outbox" | "agents" | "approvals"; interface MailboxViewProps { projectId?: string; addToast?: (msg: string, type?: "success" | "error") => void; /** Callback when unread count changes (for header badge updates) */ onUnreadCountChange?: (count: number) => void; } const ALL_AGENTS_MAILBOX_ID = "__all_agents__"; /* FNXC:Mailbox 2026-06-22-16:00: The mailbox message-list pane defaults narrow and can be dragged narrower than before. Lowered min 280->180 and default 320->220 so the conversation list takes less horizontal room by default while the active-message pane gets more; users can still widen via the resize handle (persisted per project). */ const MAILBOX_SIDEBAR_MIN_WIDTH = 180; const MAILBOX_SIDEBAR_MAX_RATIO = 0.65; const MAILBOX_SIDEBAR_KEYBOARD_STEP = 16; const MAILBOX_SIDEBAR_DEFAULT_WIDTH = 220; function getMailboxSidebarMaxWidth(containerWidth: number): number { return Math.max(MAILBOX_SIDEBAR_MIN_WIDTH, containerWidth * MAILBOX_SIDEBAR_MAX_RATIO); } function clampMailboxSidebarWidth(width: number, containerWidth: number): number { const maxWidth = getMailboxSidebarMaxWidth(containerWidth); return Math.min(Math.max(width, MAILBOX_SIDEBAR_MIN_WIDTH), maxWidth); } function readMailboxSidebarWidth(projectId?: string): number { try { const saved = getScopedItem("kb-dashboard-mailbox-sidebar-width", projectId); if (!saved) return MAILBOX_SIDEBAR_DEFAULT_WIDTH; const parsed = Number(saved); if (Number.isFinite(parsed) && parsed > 0) { return parsed; } } catch { // Invalid localStorage data - fall through to default } return MAILBOX_SIDEBAR_DEFAULT_WIDTH; } // ── Helpers ─────────────────────────────────────────────────────────────── function formatTimestamp(ts: string, t?: TFunction<"app">): string { /* * FNXC:RelativeTime 2026-06-17-20:48: * FN-6618 shares bucket math while preserving MailboxView's composed count + mailbox.ago i18n shape, future-as-Just-now behavior, and Invalid Date fallback. */ const bucket = getRelativeTimeBucket(ts); if (!bucket) { const timestampMs = Date.parse(ts); if (Number.isFinite(timestampMs) && Date.now() - timestampMs < 0) return t ? t("mailbox.justNow", "Just now") : "Just now"; return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } switch (bucket.bucket) { case "just-now": return t ? t("mailbox.justNow", "Just now") : "Just now"; case "minutes": return `${bucket.count}m ${t ? t("mailbox.ago", "ago") : "ago"}`; case "hours": return `${bucket.count}h ${t ? t("mailbox.ago", "ago") : "ago"}`; case "days": return `${bucket.count}d ${t ? t("mailbox.ago", "ago") : "ago"}`; case "weeks": case "older": return bucket.date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } } function participantLabel( id: string, type: ParticipantType, agentNamesById?: ReadonlyMap, t?: TFunction<"app">, ): string { if (type === "user") return id === "dashboard" ? (t ? t("mailbox.you", "You") : "You") : `${t ? t("mailbox.user", "User") : "User"}: ${id}`; if (type === "agent") { const name = agentNamesById?.get(id)?.trim(); if (!name) return `${t ? t("mailbox.agent", "Agent") : "Agent"}: ${id}`; if (name === id) return `${t ? t("mailbox.agent", "Agent") : "Agent"}: ${id}`; return `${t ? t("mailbox.agent", "Agent") : "Agent"}: ${name} (${id})`; } return t ? t("mailbox.system", "System") : "System"; } function messageTypeLabel(type: MessageType): string { switch (type) { case "agent-to-agent": return "Agent ↔ Agent"; case "agent-to-user": return "Agent → You"; case "user-to-agent": return "You → Agent"; case "system": return "System"; } } function messagePreview(content: string, max = 80): string { if (content.length <= max) return content; return `${content.slice(0, max)}…`; } function getDeepLinkedMessageId(): string | null { if (typeof window === "undefined") { return null; } const params = new URLSearchParams(window.location.search); const paramId = params.get("mailbox-message"); if (paramId) { return paramId; } const hashMatch = /^#message-(.+)$/.exec(window.location.hash); return hashMatch?.[1] ?? null; } function listMessageAnchorId(messageId: string): string { return `mailbox-list-message-${messageId}`; } function detailMessageAnchorId(messageId: string): string { return `mailbox-detail-message-${messageId}`; } function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] { const allMessages = [...messages]; if (!allMessages.some((message) => message.id === selectedMessage.id)) { allMessages.push(selectedMessage); } const threadIds = new Set([selectedMessage.id]); let changed = true; while (changed) { changed = false; for (const message of allMessages) { const replyToId = message.metadata?.replyTo?.messageId; if (threadIds.has(message.id) && replyToId && !threadIds.has(replyToId)) { threadIds.add(replyToId); changed = true; } if (replyToId && threadIds.has(replyToId) && !threadIds.has(message.id)) { threadIds.add(message.id); changed = true; } } } return allMessages .filter((message) => threadIds.has(message.id)) .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); } // ── Component ───────────────────────────────────────────────────────────── export function MailboxView({ projectId, addToast, onUnreadCountChange, }: MailboxViewProps) { const { t } = useTranslation("app"); const [activeTab, setActiveTab] = useState("inbox"); const [inbox, setInbox] = useState(null); const [outbox, setOutbox] = useState(null); const [unreadCount, setUnreadCount] = useState(0); const [isLoading, setIsLoading] = useState(false); const [selectedMessage, setSelectedMessage] = useState(null); const [conversationMessages, setConversationMessages] = useState([]); const [showComposer, setShowComposer] = useState(false); const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null); const [composeReplyContext, setComposeReplyContext] = useState<{ messageId: string; preview: string } | null>(null); const [selectedAgentId, setSelectedAgentId] = useState(ALL_AGENTS_MAILBOX_ID); const [agentSubTab, setAgentSubTab] = useState<"inbox" | "outbox">("inbox"); const [agentMailbox, setAgentMailbox] = useState(null); const [allAgentsMailbox, setAllAgentsMailbox] = useState(null); const [agents, setAgents] = useState([]); const [approvalSubTab, setApprovalSubTab] = useState<"pending" | "history">("pending"); const [approvals, setApprovals] = useState([]); const [approvalPendingCount, setApprovalPendingCount] = useState(0); const [selectedApproval, setSelectedApproval] = useState(null); const [approvalComment, setApprovalComment] = useState(""); const [approvalDecisionLoading, setApprovalDecisionLoading] = useState(false); const consumedDeepLinkedMessageIdRef = useRef(null); const highlightedDeepLinkedMessageIdRef = useRef(null); /* * FNXC:MailboxMobile 2026-06-23-10:55: * URL mailbox deep links initialize one message selection for reload/share flows, but mobile Back, tab switches, compose/delete/approval actions, and direct row clicks are explicit user navigation. Consume the current URL target before those actions so refresh or conversation effects cannot restore an older message over the user's chosen row. */ const consumeCurrentDeepLink = useCallback(() => { const deepLinkedMessageId = getDeepLinkedMessageId(); if (deepLinkedMessageId) { consumedDeepLinkedMessageIdRef.current = deepLinkedMessageId; } }, []); const agentNamesById = useMemo( () => new Map(agents.map((agent) => [agent.id, agent.name ?? ""])), [agents], ); const getParticipantLabel = useCallback( (id: string, type: ParticipantType) => participantLabel(id, type, agentNamesById, t), [agentNamesById, t], ); const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; const isSplitPane = !isMobile; const [sidebarWidth, setSidebarWidth] = useState(() => readMailboxSidebarWidth(projectId)); const splitLayoutRef = useRef(null); const mailboxContentRef = useRef(null); /* FNXC:Mailbox 2026-06-22-18:05: Teardown ref for the pointer-driven divider drag. The pointer move/up/cancel listeners and the captured pointer must be released exactly once on pointerup, pointercancel, or unmount; storing the cleanup here guarantees we never leak a global listener or a stuck pointer capture if the component unmounts mid-drag. */ const splitResizeTeardownRef = useRef<(() => void) | null>(null); const pendingScrollTopRef = useRef(null); const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile }); const containerKeyboardStyle = useMemo(() => { if (!keyboardOpen) { return undefined; } return { "--keyboard-overlap": `${keyboardOverlap}px`, "--vv-offset-top": `${viewportOffsetTop}px`, ...(viewportHeight != null ? { "--vv-height": `${viewportHeight}px` } : {}), } as CSSProperties; }, [keyboardOpen, keyboardOverlap, viewportHeight, viewportOffsetTop]); useEffect(() => { setSidebarWidth(readMailboxSidebarWidth(projectId)); }, [projectId]); useEffect(() => { if (!isSplitPane) return; const containerWidth = splitLayoutRef.current?.clientWidth; if (!containerWidth) return; setSidebarWidth((current) => clampMailboxSidebarWidth(current, containerWidth)); }, [isSplitPane]); useEffect(() => { if (!isSplitPane) return; try { setScopedItem("kb-dashboard-mailbox-sidebar-width", String(sidebarWidth), projectId); } catch { // localStorage persistence is best-effort. } }, [isSplitPane, projectId, sidebarWidth]); /* FNXC:Mailbox 2026-06-22-18:05: Divider drag uses pointer events + setPointerCapture so the drag keeps tracking even when the cursor leaves the thin handle. Each move maps the pointer's X to a list-pane width relative to the split-layout left edge, clamped to [MIN, container * MAX_RATIO]. setSidebarWidth feeds the pane's inline `width`, which the flex row now honors, so the resize is live; the existing persistence effect writes the final width to scoped storage. The teardown (release capture + remove listeners) runs once on pointerup/pointercancel and is parked in splitResizeTeardownRef for unmount safety. */ const handleSplitResizeStart = useCallback((event: React.PointerEvent) => { if (!isSplitPane) return; event.preventDefault(); const container = splitLayoutRef.current; if (!container) return; splitResizeTeardownRef.current?.(); const handle = event.currentTarget; const rect = container.getBoundingClientRect(); const pointerId = event.pointerId; const onPointerMove = (moveEvent: PointerEvent) => { if (moveEvent.pointerId !== pointerId) return; const proposedWidth = moveEvent.clientX - rect.left; setSidebarWidth(clampMailboxSidebarWidth(proposedWidth, rect.width)); }; const teardown = () => { handle.removeEventListener("pointermove", onPointerMove); handle.removeEventListener("pointerup", teardown); handle.removeEventListener("pointercancel", teardown); try { handle.releasePointerCapture(pointerId); } catch { // Pointer capture may already be released; ignore. } splitResizeTeardownRef.current = null; }; splitResizeTeardownRef.current = teardown; try { handle.setPointerCapture(pointerId); } catch { // setPointerCapture can throw in non-DOM test environments; drag still works via listeners. } handle.addEventListener("pointermove", onPointerMove); handle.addEventListener("pointerup", teardown); handle.addEventListener("pointercancel", teardown); }, [isSplitPane]); useEffect(() => () => { splitResizeTeardownRef.current?.(); }, []); const handleSplitResizeKeyDown = useCallback((event: React.KeyboardEvent) => { if (!isSplitPane) return; const measuredWidth = splitLayoutRef.current?.clientWidth ?? 0; const fallbackWidth = sidebarWidth / MAILBOX_SIDEBAR_MAX_RATIO + MAILBOX_SIDEBAR_KEYBOARD_STEP; const containerWidth = Math.max(measuredWidth, fallbackWidth); const maxWidth = getMailboxSidebarMaxWidth(containerWidth); if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); const delta = event.key === "ArrowLeft" ? -MAILBOX_SIDEBAR_KEYBOARD_STEP : MAILBOX_SIDEBAR_KEYBOARD_STEP; setSidebarWidth((current) => clampMailboxSidebarWidth(current + delta, containerWidth)); return; } if (event.key === "Home") { event.preventDefault(); setSidebarWidth(MAILBOX_SIDEBAR_MIN_WIDTH); return; } if (event.key === "End") { event.preventDefault(); setSidebarWidth(maxWidth); } }, [isSplitPane, sidebarWidth]); const captureMailboxScroll = useCallback(() => { if (!isMobile) { return; } const content = mailboxContentRef.current; if (!content) { return; } pendingScrollTopRef.current = content.scrollTop; }, [isMobile]); const restoreMailboxScroll = useCallback(() => { const scrollTop = pendingScrollTopRef.current; if (scrollTop === null) { return; } const restore = () => { const content = mailboxContentRef.current; if (content) { content.scrollTop = scrollTop; } pendingScrollTopRef.current = null; }; if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { window.requestAnimationFrame(restore); return; } restore(); }, []); // ── Data fetching ───────────────────────────────────────────────────── const loadInbox = useCallback(async () => { captureMailboxScroll(); setIsLoading(true); try { const data = await fetchInbox({ limit: 50 }, projectId); setInbox(data); setUnreadCount(data.unreadCount); onUnreadCountChange?.(data.unreadCount); } catch { // Silently fail — empty state will show } finally { setIsLoading(false); } }, [projectId, onUnreadCountChange, captureMailboxScroll]); const loadOutbox = useCallback(async () => { captureMailboxScroll(); setIsLoading(true); try { const data = await fetchOutbox({ limit: 50 }, projectId); setOutbox(data); } catch { // Silently fail } finally { setIsLoading(false); } }, [projectId, captureMailboxScroll]); const loadAgentMailbox = useCallback(async (agentId: string) => { captureMailboxScroll(); setIsLoading(true); try { const data = await fetchAgentMailbox(agentId, projectId); setAgentMailbox(data); } catch { // Silently fail } finally { setIsLoading(false); } }, [projectId, captureMailboxScroll]); const loadAllAgentsMailbox = useCallback(async () => { captureMailboxScroll(); setIsLoading(true); try { const data = await fetchAllAgentMailbox(projectId); setAllAgentsMailbox(data); } catch { // Silently fail } finally { setIsLoading(false); } }, [projectId, captureMailboxScroll]); useEffect(() => { restoreMailboxScroll(); }, [inbox, outbox, agentMailbox, allAgentsMailbox, approvals, selectedApproval, restoreMailboxScroll]); const loadAgents = useCallback(async () => { try { const data = await fetchAgents(undefined, projectId); setAgents(data); } catch { // Silently fail } }, [projectId]); const refreshUnreadCount = useCallback(async () => { try { const data = await fetchUnreadCount(projectId); setUnreadCount(data.unreadCount); setApprovalPendingCount(data.pendingApprovalCount ?? 0); onUnreadCountChange?.(data.unreadCount); } catch { // Silently fail } }, [projectId, onUnreadCountChange]); const loadApprovals = useCallback(async (status: "pending" | "history") => { captureMailboxScroll(); setIsLoading(true); try { const list = await fetchApprovals({ status: status === "pending" ? "pending" : undefined, limit: 100 }, projectId); if (status === "pending") { setApprovals(list.requests); } else { const [approved, denied, completed] = await Promise.all([ fetchApprovals({ status: "approved", limit: 100 }, projectId), fetchApprovals({ status: "denied", limit: 100 }, projectId), fetchApprovals({ status: "completed", limit: 100 }, projectId), ]); setApprovals([...approved.requests, ...denied.requests, ...completed.requests].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))); } setApprovalPendingCount(list.pendingCount); } catch { // Silently fail } finally { setIsLoading(false); } }, [projectId, captureMailboxScroll]); // Load data on tab change useEffect(() => { if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); else if (activeTab === "agents") loadAgents(); else if (activeTab === "approvals") { void loadApprovals(approvalSubTab); } }, [activeTab, loadInbox, loadOutbox, loadAgents, loadApprovals, approvalSubTab]); // Load agent mailbox when selected useEffect(() => { if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) { void loadAllAgentsMailbox(); return; } void loadAgentMailbox(selectedAgentId); }, [selectedAgentId, loadAgentMailbox, loadAllAgentsMailbox]); // Load unread count on mount useEffect(() => { refreshUnreadCount(); }, [refreshUnreadCount]); // Load agents on mount so they're available for compose from any tab (not just agents tab) useEffect(() => { loadAgents(); }, [loadAgents]); // Subscribe to mailbox SSE events for near-real-time refresh. useEffect(() => { if (typeof EventSource === "undefined") { return; } const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const onMailboxUpdate = () => { void refreshUnreadCount(); if (activeTab === "inbox") { void loadInbox(); } else if (activeTab === "outbox") { void loadOutbox(); } else if (activeTab === "approvals") { void loadApprovals(approvalSubTab); } if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) { void loadAllAgentsMailbox(); } else if (selectedAgentId) { void loadAgentMailbox(selectedAgentId); } }; return subscribeSse(`/api/events${query}`, { events: { "message:sent": onMailboxUpdate, "message:received": onMailboxUpdate, "message:read": onMailboxUpdate, "message:deleted": onMailboxUpdate, "approval:requested": onMailboxUpdate, "approval:updated": onMailboxUpdate, "approval:decided": onMailboxUpdate, }, }); }, [projectId, activeTab, selectedAgentId, refreshUnreadCount, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, loadApprovals, approvalSubTab]); // ── Actions ─────────────────────────────────────────────────────────── const handleOpenMessage = useCallback(async (message: Message, source: "deep-link" | "user" = "user") => { if (source === "user") { consumeCurrentDeepLink(); } setSelectedMessage(message); // Only auto-mark as read when viewing the dashboard user's own inbox. // Browsing another agent's mailbox must not consume their unread messages // out from under them — the agent's heartbeat is the one that reads + acks. if (!message.read && activeTab === "inbox") { try { const updated = await markMessageRead(message.id, projectId); // Update inbox state if (updated) { setInbox((prev) => prev ? { ...prev, messages: prev.messages.map((m) => (m.id === updated.id ? updated : m)), unreadCount: Math.max(0, prev.unreadCount - 1), } : prev, ); } const newCount = Math.max(0, unreadCount - 1); setUnreadCount(newCount); onUnreadCountChange?.(newCount); } catch { // Non-critical } } // Load conversation thread try { const conv = await fetchConversation(message.fromId, message.fromType, projectId); setConversationMessages(conv); } catch { setConversationMessages([message]); } }, [projectId, unreadCount, onUnreadCountChange, activeTab, consumeCurrentDeepLink]); // Deep-link: open and highlight a specific message from URL params. useEffect(() => { const deepLinkedMessageId = getDeepLinkedMessageId(); if (!deepLinkedMessageId || consumedDeepLinkedMessageIdRef.current === deepLinkedMessageId) { return; } const message = [ ...(inbox?.messages ?? []), ...(outbox?.messages ?? []), ...(agentMailbox?.inbox ?? []), ...(agentMailbox?.outbox ?? []), ...(allAgentsMailbox?.messages ?? []), ...conversationMessages, ].find((candidate) => candidate.id === deepLinkedMessageId); if (!message) { return; } consumedDeepLinkedMessageIdRef.current = deepLinkedMessageId; void handleOpenMessage(message, "deep-link"); }, [inbox, outbox, agentMailbox, allAgentsMailbox, conversationMessages, handleOpenMessage]); useEffect(() => { const deepLinkedMessageId = getDeepLinkedMessageId(); if (!deepLinkedMessageId || selectedMessage?.id !== deepLinkedMessageId || highlightedDeepLinkedMessageIdRef.current === deepLinkedMessageId) { return; } const element = document.getElementById(detailMessageAnchorId(deepLinkedMessageId)); if (!element) { return; } highlightedDeepLinkedMessageIdRef.current = deepLinkedMessageId; element.scrollIntoView({ behavior: "smooth", block: "center" }); element.classList.add("mailbox-message-highlight"); const timer = window.setTimeout(() => { element.classList.remove("mailbox-message-highlight"); }, 2000); return () => { window.clearTimeout(timer); }; }, [selectedMessage, conversationMessages]); const handleCloseMessage = useCallback(() => { consumeCurrentDeepLink(); setSelectedMessage(null); setConversationMessages([]); }, [consumeCurrentDeepLink]); const handleMarkAllRead = useCallback(async () => { try { const result = await markAllMessagesRead(projectId); setUnreadCount(0); onUnreadCountChange?.(0); setInbox((prev) => prev ? { ...prev, messages: prev.messages.map((m) => ({ ...m, read: true })), unreadCount: 0, } : prev, ); addToast?.(`Marked ${result.markedAsRead} messages as read`, "success"); } catch { addToast?.("Failed to mark messages as read", "error"); } }, [projectId, addToast, onUnreadCountChange]); const handleDeleteMessage = useCallback(async (id: string) => { consumeCurrentDeepLink(); try { await deleteMessage(id, projectId); setSelectedMessage(null); setConversationMessages([]); // Refresh current tab if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox(); else if (selectedAgentId) loadAgentMailbox(selectedAgentId); addToast?.("Message deleted", "success"); } catch { addToast?.("Failed to delete message", "error"); } }, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, addToast, consumeCurrentDeepLink]); const handleReply = useCallback((message: Message) => { consumeCurrentDeepLink(); setComposeRecipient({ id: message.fromId, type: message.fromType }); setComposeReplyContext({ messageId: message.id, preview: messagePreview(message.content, 120), }); setShowComposer(true); }, [consumeCurrentDeepLink]); const handleMessageSent = useCallback(() => { setShowComposer(false); setComposeRecipient(null); setComposeReplyContext(null); addToast?.("Message sent", "success"); // Refresh current tab if (activeTab === "outbox") loadOutbox(); else if (activeTab === "agents" && selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox(); else if (activeTab === "agents" && selectedAgentId) loadAgentMailbox(selectedAgentId); refreshUnreadCount(); }, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, loadAllAgentsMailbox, addToast, refreshUnreadCount]); const handleOpenCompose = useCallback(() => { consumeCurrentDeepLink(); // Pre-fill recipient from selected agent if available if (activeTab === "agents" && selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID) { setComposeRecipient({ id: selectedAgentId, type: "agent" }); } else { setComposeRecipient(null); } setComposeReplyContext(null); setShowComposer(true); }, [activeTab, selectedAgentId, consumeCurrentDeepLink]); const handleComposeCancel = useCallback(() => { consumeCurrentDeepLink(); setShowComposer(false); setComposeRecipient(null); setComposeReplyContext(null); }, [consumeCurrentDeepLink]); const handleOpenApproval = useCallback(async (request: ApprovalRequestSummary) => { consumeCurrentDeepLink(); try { const detail = await fetchApprovalDetail(request.id, projectId); setSelectedApproval(detail); setApprovalComment(""); } catch { addToast?.("Failed to load approval request", "error"); } }, [projectId, addToast, consumeCurrentDeepLink]); const handleApprovalDecision = useCallback(async (decision: "approve" | "deny") => { if (!selectedApproval || approvalDecisionLoading) return; setApprovalDecisionLoading(decision); try { await decideApproval(selectedApproval.id, { decision, comment: approvalComment || undefined }, projectId); await loadApprovals(approvalSubTab); const updated = await fetchApprovalDetail(selectedApproval.id, projectId); setSelectedApproval(updated); setApprovalComment(""); addToast?.(`Request ${decision === "approve" ? "approved" : "denied"}`, "success"); } catch { addToast?.("Failed to submit decision", "error"); } finally { setApprovalDecisionLoading(false); } }, [selectedApproval, approvalDecisionLoading, approvalComment, projectId, loadApprovals, approvalSubTab, addToast]); // ── Render ──────────────────────────────────────────────────────────── const renderMessageDetail = () => { if (!selectedMessage || showComposer) return null; const threadMessages = buildReplyThread(conversationMessages, selectedMessage); return (
{isMobile && ( )}
{messageTypeLabel(selectedMessage.type)} {formatTimestamp(selectedMessage.createdAt, t)}
{selectedMessage.fromType === "agent" && ( )}
{t("mailbox.from", "From")}: {selectedMessage.fromType === "agent" ? : } {getParticipantLabel(selectedMessage.fromId, selectedMessage.fromType)}
{t("mailbox.to", "To")}: {selectedMessage.toType === "agent" ? : } {getParticipantLabel(selectedMessage.toId, selectedMessage.toType)}
{threadMessages.length > 1 && (
{t("mailbox.conversation", "Conversation")}
{threadMessages.map((msg) => { const replyToId = msg.metadata?.replyTo?.messageId; const replyToMessage = replyToId ? threadMessages.find((candidate) => candidate.id === replyToId) : undefined; return (
{getParticipantLabel(msg.fromId, msg.fromType)} {formatTimestamp(msg.createdAt, t)}
{replyToId && (
↪ {t("mailbox.replyingTo", "Replying to")} {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
)}
); })}
)} {(threadMessages.length <= 1) && ( <> {selectedMessage.metadata?.replyTo?.messageId && (
↪ {t("mailbox.replyingToMessage", "Replying to message")} {selectedMessage.metadata.replyTo.messageId}
)} )}
); }; const renderListPane = () => ( <> {activeTab === "inbox" && (
{isLoading && !inbox && } {inbox && inbox.messages.length === 0 && (

{t("mailbox.noMessagesInbox", "No messages in your inbox")}

)} {inbox?.messages.map((msg) => (
handleOpenMessage(msg)} data-testid={`mailbox-item-${msg.id}`} >
{msg.fromType === "agent" ? : }
{getParticipantLabel(msg.fromId, msg.fromType)} {formatTimestamp(msg.createdAt, t)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
{!msg.read &&
}
))}
)} {activeTab === "outbox" && (
{isLoading && !outbox && } {outbox && outbox.messages.length === 0 && (

{t("mailbox.noSentMessages", "No sent messages")}

)} {outbox?.messages.map((msg) => (
handleOpenMessage(msg)} data-testid={`mailbox-item-${msg.id}`} >
{msg.toType === "agent" ? : }
{t("mailbox.toRecipient", "To: {{recipient}}", { recipient: getParticipantLabel(msg.toId, msg.toType) })} {formatTimestamp(msg.createdAt, t)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
))}
)} {activeTab === "approvals" && (
{approvals.length === 0 && !isLoading && (

{approvalSubTab === "pending" ? t("mailbox.noPendingApprovals", "No pending approvals") : t("mailbox.noHistoricalApprovals", "No historical approvals")}

)} {approvals.map((request) => (
void handleOpenApproval(request)} data-testid={`mailbox-approval-item-${request.id}`} >
{request.agentId} · {request.actionCategory} {formatTimestamp(request.createdAt)}
{request.actionSummary}
{request.status}
))}
)} {activeTab === "agents" && (
{agents.length === 0 ? (

{t("mailbox.noAgentsFound", "No agents found")}

) : ( <>
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && (
)}
{selectedAgentId === ALL_AGENTS_MAILBOX_ID && isLoading && !allAgentsMailbox && } {selectedAgentId === ALL_AGENTS_MAILBOX_ID && allAgentsMailbox && allAgentsMailbox.messages.length === 0 && (

{t("mailbox.noAgentMessages", "No agent-to-agent messages")}

)} {selectedAgentId === ALL_AGENTS_MAILBOX_ID && allAgentsMailbox && allAgentsMailbox.messages.map((msg) => (
handleOpenMessage(msg)} data-testid={`mailbox-item-${msg.id}`} >
{msg.fromType === "agent" ? : }
{getParticipantLabel(msg.fromId, msg.fromType)} {formatTimestamp(msg.createdAt, t)}
{t("mailbox.from", "From")}: {getParticipantLabel(msg.fromId, msg.fromType)} {t("mailbox.to", "To")}: {getParticipantLabel(msg.toId, msg.toType)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
))} {selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && isLoading && !agentMailbox && } {selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.length === 0 && (

{t("mailbox.noReceivedMessages", "No received messages for this agent")}

)} {selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.length === 0 && (

{t("mailbox.noSentMessagesAgent", "No sent messages for this agent")}

)} {selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
handleOpenMessage(msg)} data-testid={`mailbox-item-${msg.id}`} >
{msg.fromType === "agent" ? : }
{getParticipantLabel(msg.fromId, msg.fromType)} {formatTimestamp(msg.createdAt, t)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
))} {selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
handleOpenMessage(msg)} data-testid={`mailbox-item-${msg.id}`} >
{msg.toType === "agent" ? : }
{t("mailbox.toRecipient", "To: {{recipient}}", { recipient: getParticipantLabel(msg.toId, msg.toType) })} {formatTimestamp(msg.createdAt, t)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
))}
)}
)} ); const renderDetailPane = () => { if (showComposer) { return ( ); } if (selectedMessage) { return renderMessageDetail(); } if (activeTab === "approvals" && selectedApproval) { return (
{isMobile && ( )}
{selectedApproval.actionCategory} {selectedApproval.status}
{selectedApproval.actionSummary}

{t("mailbox.approvalRequester", "Requester")}: {selectedApproval.requester.actorName} ({selectedApproval.agentId})

{selectedApproval.taskId &&

{t("mailbox.approvalTask", "Task")}: {selectedApproval.taskId}

}

{t("mailbox.approvalRequested", "Requested")}: {formatTimestamp(selectedApproval.createdAt)}

{selectedApproval.targetAction.category === "network_api" && selectedApproval.targetAction.action === "worktrunk_install" && ( )}
{selectedApproval.history.map((event) => (
{event.eventType} {event.actor.actorName}
{event.note &&
{event.note}
}
))}
{selectedApproval.status === "pending" && (