feat(FN-1634): promote mailbox to first-class navigation view

- Add MailboxView as a full-page navigation view replacing the modal-based approach
- Migrate MessageStore from filesystem to SQLite backend for message persistence
- Implement conversation grouping for inbox display with unread badge state
- Remove modal plumbing (useModalManager, AppModals exports) and simplify App.tsx
- Add evictStaleProcessing() to TriageProcessor for self-healing hung triage sessions
- Add comprehensive MailboxView tests and Header mailbox tests
- Update README documentation with MailboxView features
- Add CSS styles for MailboxView component
- Fix MobileNavBar tests for mailbox tab visibility
This commit is contained in:
Fusion
2026-04-15 08:03:12 -07:00
committed by gsxdsm
parent cb54000fe5
commit 647a10b161
21 changed files with 1693 additions and 103 deletions

View File

@@ -10,6 +10,7 @@ import { NodesView } from "./components/NodesView";
import { ChatView } from "./components/ChatView";
import { RoadmapsView } from "./components/RoadmapsView";
import { SkillsView } from "./components/SkillsView";
import { MailboxView } from "./components/MailboxView";
import { PageErrorBoundary } from "./components/ErrorBoundary";
import { AppModals } from "./components/AppModals";
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
@@ -39,7 +40,7 @@ import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
import { NodeProvider, useNodeContext } from "./context/NodeContext";
import type { AiSessionSummary } from "./api";
import { fetchAiSession } from "./api";
import { fetchAiSession, fetchUnreadCount } from "./api";
function AppInner() {
const { toasts, addToast, removeToast } = useToast();
@@ -124,6 +125,18 @@ function AppInner() {
planningSessions: bgPlanningSessions,
});
// App-level mailbox unread count state (used for header badge)
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
// Initial fetch of mailbox unread count
useEffect(() => {
fetchUnreadCount(currentProject?.id)
.then((data: { unreadCount: number }) => {
setMailboxUnreadCount(data.unreadCount);
})
.catch(() => {});
}, [currentProject?.id]);
// Nodes management is an overlay view (not a modal), so it stays local to App.
const [nodesOpen, setNodesOpen] = useState(false);
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
@@ -335,6 +348,18 @@ function AppInner() {
);
}
if (taskView === "mailbox") {
return (
<PageErrorBoundary>
<MailboxView
projectId={currentProject?.id}
addToast={addToast}
onUnreadCountChange={setMailboxUnreadCount}
/>
</PageErrorBoundary>
);
}
if (taskView === "roadmaps") {
return (
<PageErrorBoundary>
@@ -463,8 +488,8 @@ function AppInner() {
activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={modalManager.openUsage}
onOpenActivityLog={modalManager.openActivityLog}
onOpenMailbox={modalManager.openMailbox}
mailboxUnreadCount={modalManager.mailboxUnreadCount}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount}
onOpenSchedules={modalManager.openSchedules}
onOpenGitManager={modalManager.openGitManager}
onOpenNodes={handleOpenNodes}
@@ -536,8 +561,8 @@ function AppInner() {
modalOpen={modalManager.anyModalOpen}
onOpenSettings={handleOpenSettings}
onOpenActivityLog={modalManager.openActivityLog}
onOpenMailbox={modalManager.openMailbox}
mailboxUnreadCount={modalManager.mailboxUnreadCount}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount}
onOpenGitManager={modalManager.openGitManager}
onOpenWorkflowSteps={modalManager.openWorkflowSteps}
onOpenSchedules={modalManager.openSchedules}
@@ -554,7 +579,7 @@ function AppInner() {
onOpenQuickChat={() => setQuickChatOpen(true)}
projectId={currentProject?.id}
/>
{viewMode === "project" && currentProject && taskView !== "chat" && (
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && (
<QuickChatFAB
projectId={currentProject.id}
addToast={addToast}

View File

@@ -20,7 +20,6 @@ import { ActivityLogModal } from "./ActivityLogModal";
import { GitManagerModal } from "./GitManagerModal";
import { WorkflowStepManager } from "./WorkflowStepManager";
import { AgentListModal } from "./AgentListModal";
import { MailboxModal } from "./MailboxModal";
import { SetupWizardModal } from "./SetupWizardModal";
import { ModelOnboardingModal } from "./ModelOnboardingModal";
import { ToastContainer } from "./ToastContainer";
@@ -242,14 +241,6 @@ export function AppModals({
projectId={projectId}
/>
<MailboxModal
isOpen={modalManager.mailboxOpen}
onClose={modalManager.closeMailbox}
projectId={projectId}
addToast={addToast}
agents={modalManager.mailboxAgents}
/>
{modalManager.setupWizardOpen && (
<SetupWizardModal
onProjectRegistered={projectActions.handleSetupComplete}

View File

@@ -162,7 +162,7 @@ export interface HeaderProps {
activePlanningSessionCount?: number;
onOpenUsage?: () => void;
onOpenActivityLog?: () => void;
/** Opens the mailbox modal */
/** Opens the mailbox view */
onOpenMailbox?: () => void;
/** Unread message count for badge display */
mailboxUnreadCount?: number;
@@ -180,8 +180,8 @@ export interface HeaderProps {
enginePaused?: boolean;
onToggleGlobalPause?: () => void;
onToggleEnginePause?: () => void;
view?: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills";
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills") => void;
view?: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox";
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox") => void;
searchQuery?: string;
onSearchChange?: (query: string) => void;
/** Multi-project props */
@@ -675,6 +675,15 @@ export function Header({
>
<MessageSquare size={16} />
</button>
<button
className={`view-toggle-btn${view === "mailbox" ? " active" : ""}`}
onClick={() => onChangeView("mailbox")}
title="Mailbox view"
aria-label="Mailbox view"
aria-pressed={view === "mailbox"}
>
<Mail size={16} />
</button>
<button
className={`view-toggle-btn${view === "skills" ? " active" : ""}`}
onClick={() => onChangeView("skills")}

View File

@@ -0,0 +1,691 @@
import { useState, useEffect, useCallback } from "react";
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,
markMessageRead,
markAllMessagesRead,
deleteMessage,
fetchConversation,
fetchAgents,
type InboxResponse,
type OutboxResponse,
type AgentMailboxResponse,
type Agent,
} from "../api";
import { MessageComposer } from "./MessageComposer";
// ── Types ─────────────────────────────────────────────────────────────────
type MailboxTab = "inbox" | "outbox" | "agents";
interface MailboxViewProps {
projectId?: string;
addToast?: (msg: string, type?: "success" | "error") => void;
/** Callback when unread count changes (for header badge updates) */
onUnreadCountChange?: (count: number) => void;
}
/** Represents a grouped conversation in the inbox */
interface ConversationGroup {
/** Unique key combining fromId and fromType */
key: string;
fromId: string;
fromType: ParticipantType;
/** Latest message in the conversation */
latestMessage: Message;
/** All messages in this conversation */
messages: Message[];
/** Count of unread messages in this conversation */
unreadCount: number;
}
// ── Helpers ───────────────────────────────────────────────────────────────
function formatTimestamp(ts: string): string {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function participantLabel(id: string, type: ParticipantType): string {
if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`;
if (type === "agent") return `Agent: ${id}`;
return "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";
}
}
/** Groups messages by conversation (sender) key */
function groupMessagesByConversation(messages: Message[]): ConversationGroup[] {
const groups = new Map<string, ConversationGroup>();
for (const msg of messages) {
const key = `${msg.fromType}:${msg.fromId}`;
const existing = groups.get(key);
if (existing) {
existing.messages.push(msg);
// Track latest by timestamp
if (new Date(msg.createdAt) > new Date(existing.latestMessage.createdAt)) {
existing.latestMessage = msg;
}
// Update unread count
if (!msg.read) {
existing.unreadCount++;
}
} else {
groups.set(key, {
key,
fromId: msg.fromId,
fromType: msg.fromType,
latestMessage: msg,
messages: [msg],
unreadCount: msg.read ? 0 : 1,
});
}
}
// Sort by latest message timestamp, newest first
return Array.from(groups.values()).sort(
(a, b) => new Date(b.latestMessage.createdAt).getTime() - new Date(a.latestMessage.createdAt).getTime()
);
}
// ── Component ─────────────────────────────────────────────────────────────
export function MailboxView({
projectId,
addToast,
onUnreadCountChange,
}: MailboxViewProps) {
const [activeTab, setActiveTab] = useState<MailboxTab>("inbox");
const [inbox, setInbox] = useState<InboxResponse | null>(null);
const [outbox, setOutbox] = useState<OutboxResponse | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const [conversationMessages, setConversationMessages] = useState<Message[]>([]);
const [showComposer, setShowComposer] = useState(false);
const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
const [agents, setAgents] = useState<Agent[]>([]);
// ── Data fetching ─────────────────────────────────────────────────────
const loadInbox = useCallback(async () => {
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]);
const loadOutbox = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchOutbox({ limit: 50 }, projectId);
setOutbox(data);
} catch {
// Silently fail
} finally {
setIsLoading(false);
}
}, [projectId]);
const loadAgentMailbox = useCallback(async (agentId: string) => {
setIsLoading(true);
try {
const data = await fetchAgentMailbox(agentId, projectId);
setAgentMailbox(data);
} catch {
// Silently fail
} finally {
setIsLoading(false);
}
}, [projectId]);
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);
onUnreadCountChange?.(data.unreadCount);
} catch {
// Silently fail
}
}, [projectId, onUnreadCountChange]);
// Load data on tab change
useEffect(() => {
if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox();
else if (activeTab === "agents") loadAgents();
}, [activeTab, loadInbox, loadOutbox, loadAgents]);
// Load agent mailbox when selected
useEffect(() => {
if (!selectedAgentId) return;
loadAgentMailbox(selectedAgentId);
}, [selectedAgentId, loadAgentMailbox]);
// Load unread count on mount
useEffect(() => {
refreshUnreadCount();
}, [refreshUnreadCount]);
// ── Actions ───────────────────────────────────────────────────────────
const handleOpenMessage = useCallback(async (message: Message) => {
setSelectedMessage(message);
// Mark as read if unread
if (!message.read) {
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]);
const handleCloseMessage = useCallback(() => {
setSelectedMessage(null);
setConversationMessages([]);
}, []);
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) => {
try {
await deleteMessage(id, projectId);
setSelectedMessage(null);
setConversationMessages([]);
// Refresh current tab
if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox();
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
addToast?.("Message deleted", "success");
} catch {
addToast?.("Failed to delete message", "error");
}
}, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, addToast]);
const handleReply = useCallback((message: Message) => {
setComposeRecipient({ id: message.fromId, type: message.fromType });
setShowComposer(true);
}, []);
const handleMessageSent = useCallback(() => {
setShowComposer(false);
setComposeRecipient(null);
addToast?.("Message sent", "success");
// Refresh current tab
if (activeTab === "outbox") loadOutbox();
else if (activeTab === "agents" && selectedAgentId) loadAgentMailbox(selectedAgentId);
refreshUnreadCount();
}, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, addToast, refreshUnreadCount]);
const handleOpenCompose = useCallback(() => {
// Pre-fill recipient from selected agent if available
if (activeTab === "agents" && selectedAgentId) {
setComposeRecipient({ id: selectedAgentId, type: "agent" });
} else {
setComposeRecipient(null);
}
setShowComposer(true);
}, [activeTab, selectedAgentId]);
const handleComposeCancel = useCallback(() => {
setShowComposer(false);
setComposeRecipient(null);
}, []);
// ── Render ────────────────────────────────────────────────────────────
return (
<div className="mailbox-view" data-testid="mailbox-view">
{/* Header */}
<div className="mailbox-header">
<div className="mailbox-title">
<Mail size={18} />
<span>Mailbox</span>
{unreadCount > 0 && (
<span className="mailbox-unread-badge" data-testid="mailbox-unread-badge">
{unreadCount}
</span>
)}
</div>
<div className="mailbox-header-actions">
{activeTab === "inbox" && unreadCount > 0 && (
<button
className="btn-sm btn-secondary"
onClick={handleMarkAllRead}
title="Mark all as read"
data-testid="mailbox-mark-all-read"
>
<CheckCheck size={14} />
<span>Mark all read</span>
</button>
)}
<button
className="btn-icon"
onClick={() => {
if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox();
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
}}
disabled={isLoading}
title="Refresh"
data-testid="mailbox-refresh"
>
{isLoading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
</button>
</div>
</div>
{/* Tabs */}
<div className="mailbox-tabs" data-testid="mailbox-tabs">
<button
className={`mailbox-tab ${activeTab === "inbox" ? "active" : ""}`}
onClick={() => { setActiveTab("inbox"); setSelectedMessage(null); }}
data-testid="mailbox-tab-inbox"
>
<InboxIcon size={14} />
<span>Inbox</span>
{unreadCount > 0 && <span className="mailbox-tab-badge">{unreadCount}</span>}
</button>
<button
className={`mailbox-tab ${activeTab === "outbox" ? "active" : ""}`}
onClick={() => { setActiveTab("outbox"); setSelectedMessage(null); }}
data-testid="mailbox-tab-outbox"
>
<Send size={14} />
<span>Outbox</span>
</button>
<button
className={`mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
onClick={() => { setActiveTab("agents"); setSelectedMessage(null); }}
data-testid="mailbox-tab-agents"
>
<Bot size={14} />
<span>Agents</span>
</button>
</div>
{/* Content */}
<div className="mailbox-content" data-testid="mailbox-content">
{/* Message Detail View */}
{selectedMessage && !showComposer && (
<div className="mailbox-message-detail" data-testid="mailbox-message-detail">
<div className="mailbox-message-detail-header">
<button
className="btn-sm btn-secondary"
onClick={handleCloseMessage}
data-testid="mailbox-back-to-list"
>
Back
</button>
<div className="mailbox-message-detail-meta">
<span className="mailbox-message-type">{messageTypeLabel(selectedMessage.type)}</span>
<span className="mailbox-message-time">{formatTimestamp(selectedMessage.createdAt)}</span>
</div>
<div className="mailbox-message-detail-actions">
{selectedMessage.fromType === "agent" && (
<button
className="btn-sm btn-secondary"
onClick={() => handleReply(selectedMessage)}
data-testid="mailbox-reply"
>
<MessageSquare size={14} />
<span>Reply</span>
</button>
)}
<button
className="btn-sm btn-secondary"
onClick={() => handleDeleteMessage(selectedMessage.id)}
data-testid="mailbox-delete"
>
<Trash2 size={14} />
<span>Delete</span>
</button>
</div>
</div>
<div className="mailbox-message-participants">
<div className="mailbox-participant">
<span className="mailbox-participant-label">From:</span>
<span className="mailbox-participant-value">
{selectedMessage.fromType === "agent" ? <Bot size={14} /> : <User size={14} />}
{participantLabel(selectedMessage.fromId, selectedMessage.fromType)}
</span>
</div>
<div className="mailbox-participant">
<span className="mailbox-participant-label">To:</span>
<span className="mailbox-participant-value">
{selectedMessage.toType === "agent" ? <Bot size={14} /> : <User size={14} />}
{participantLabel(selectedMessage.toId, selectedMessage.toType)}
</span>
</div>
</div>
{/* Conversation thread */}
{conversationMessages.length > 1 && (
<div className="mailbox-conversation" data-testid="mailbox-conversation">
<div className="mailbox-conversation-label">Conversation</div>
{conversationMessages.map((msg) => (
<div
key={msg.id}
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
>
<div className="mailbox-conversation-msg-header">
<span>{participantLabel(msg.fromId, msg.fromType)}</span>
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
</div>
<div className="mailbox-conversation-msg-body">{msg.content}</div>
</div>
))}
</div>
)}
{/* Full message content */}
{(conversationMessages.length <= 1) && (
<div className="mailbox-message-body" data-testid="mailbox-message-body">
{selectedMessage.content}
</div>
)}
</div>
)}
{/* Message Composer */}
{showComposer && (
<MessageComposer
recipient={composeRecipient}
agents={agents}
projectId={projectId}
onSend={handleMessageSent}
onCancel={handleComposeCancel}
addToast={addToast}
/>
)}
{/* Tab Content — message lists */}
{!selectedMessage && !showComposer && (
<>
{/* Inbox Tab - Grouped by conversation */}
{activeTab === "inbox" && (
<div className="mailbox-list" data-testid="mailbox-inbox-list">
{isLoading && !inbox && <MailboxSkeleton />}
{inbox && inbox.messages.length === 0 && (
<div className="mailbox-empty" data-testid="mailbox-inbox-empty">
<InboxIcon size={32} />
<p>No messages in your inbox</p>
</div>
)}
{inbox && inbox.messages.length > 0 && (
<div className="mailbox-conversations" data-testid="mailbox-conversations">
{groupMessagesByConversation(inbox.messages).map((group) => (
<div
key={group.key}
className={`mailbox-conversation-group ${group.unreadCount > 0 ? "unread" : ""}`}
onClick={() => handleOpenMessage(group.latestMessage)}
data-testid={`mailbox-conversation-${group.key}`}
>
<div className="mailbox-item-avatar">
{group.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-from">
{participantLabel(group.fromId, group.fromType)}
</span>
<span className="mailbox-item-time">
{formatTimestamp(group.latestMessage.createdAt)}
</span>
</div>
<div className="mailbox-item-preview">
{group.latestMessage.content.slice(0, 80)}
{group.latestMessage.content.length > 80 ? "…" : ""}
</div>
</div>
{group.unreadCount > 0 && (
<div className="mailbox-group-unread-badge" data-testid={`mailbox-unread-badge-${group.key}`}>
{group.unreadCount > 9 ? "9+" : group.unreadCount}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Outbox Tab */}
{activeTab === "outbox" && (
<div className="mailbox-list" data-testid="mailbox-outbox-list">
{isLoading && !outbox && <MailboxSkeleton />}
{outbox && outbox.messages.length === 0 && (
<div className="mailbox-empty" data-testid="mailbox-outbox-empty">
<Send size={32} />
<p>No sent messages</p>
</div>
)}
{outbox?.messages.map((msg) => (
<div
key={msg.id}
className="mailbox-item"
onClick={() => handleOpenMessage(msg)}
data-testid={`mailbox-item-${msg.id}`}
>
<div className="mailbox-item-avatar">
{msg.toType === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-to">
To: {participantLabel(msg.toId, msg.toType)}
</span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div>
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
</div>
</div>
))}
</div>
)}
{/* Agent Mailboxes Tab */}
{activeTab === "agents" && (
<div className="mailbox-agents" data-testid="mailbox-agents">
{agents.length === 0 ? (
<div className="mailbox-empty">
<Bot size={32} />
<p>No agents found</p>
</div>
) : (
<>
<div className="mailbox-agents-header">
<div className="mailbox-agents-dropdown">
<select
className="message-composer-select mailbox-agent-select"
value={selectedAgentId ?? ""}
onChange={(e) => setSelectedAgentId(e.target.value || null)}
data-testid="mailbox-agent-select"
>
<option value="">Select an agent</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name || agent.id}
</option>
))}
</select>
</div>
<button
className="btn-sm btn-secondary mailbox-compose-btn"
onClick={handleOpenCompose}
data-testid="mailbox-compose-btn"
>
<MessageSquare size={14} />
<span>Compose</span>
</button>
</div>
<div className="mailbox-agents-content">
{!selectedAgentId && (
<div className="mailbox-empty">
<Bot size={32} />
<p>Select an agent to view their mailbox</p>
</div>
)}
{selectedAgentId && isLoading && !agentMailbox && <MailboxSkeleton />}
{agentMailbox && agentMailbox.messages.length === 0 && (
<div className="mailbox-empty">
<InboxIcon size={32} />
<p>No messages for this agent</p>
</div>
)}
{agentMailbox?.messages.map((msg) => (
<div
key={msg.id}
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
onClick={() => handleOpenMessage(msg)}
data-testid={`mailbox-item-${msg.id}`}
>
<div className="mailbox-item-avatar">
{msg.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-from">
{msg.fromType === "agent"
? participantLabel(msg.toId, msg.toType)
: participantLabel(msg.fromId, msg.fromType)}
</span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div>
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
</div>
</div>
))}
</div>
</>
)}
</div>
)}
</>
)}
</div>
{/* Compose FAB (only when viewing inbox/outbox, not in detail view or agents tab) */}
{!selectedMessage && !showComposer && activeTab !== "agents" && (
<button
className="mailbox-compose-fab"
onClick={handleOpenCompose}
title="Compose message"
data-testid="mailbox-compose-fab"
>
<MessageSquare size={20} />
</button>
)}
</div>
);
}
// ── Skeleton ──────────────────────────────────────────────────────────────
function MailboxSkeleton() {
return (
<div className="mailbox-skeleton" data-testid="mailbox-skeleton">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="mailbox-skeleton-item">
<div className="mailbox-skeleton-avatar" />
<div className="mailbox-skeleton-content">
<div className="mailbox-skeleton-line mailbox-skeleton-line--short" />
<div className="mailbox-skeleton-line mailbox-skeleton-line--long" />
</div>
</div>
))}
</div>
);
}

View File

@@ -28,9 +28,9 @@ import { useViewportMode } from "./Header";
export interface MobileNavBarProps {
/** Current task view mode */
view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills";
view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox";
/** Change task view handler */
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills") => void;
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox") => void;
/** Whether the ExecutorStatusBar footer is visible */
footerVisible: boolean;
/** Whether any full-screen modal is currently open (hides the tab bar) */
@@ -229,6 +229,21 @@ export function MobileNavBar({
<span className="mobile-nav-tab-label">Chat</span>
</button>
<button
type="button"
className={`mobile-nav-tab${view === "mailbox" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-mailbox"
role="tab"
aria-selected={view === "mailbox"}
onClick={() => onChangeView("mailbox")}
>
<Mail />
<span className="mobile-nav-tab-label">Mailbox</span>
{mailboxUnreadCount > 0 && (
<span className="mobile-nav-tab-badge">{formatCount(mailboxUnreadCount)}</span>
)}
</button>
<button
type="button"
className={`mobile-nav-tab${view === "skills" ? " mobile-nav-tab--active" : ""}`}

View File

@@ -880,7 +880,6 @@ import { ActivityLogModal } from "./ActivityLogModal";
import { GitManagerModal } from "./GitManagerModal";
import { WorkflowStepManager } from "./WorkflowStepManager";
import { AgentListModal } from "./AgentListModal";
import { MailboxModal } from "./MailboxModal";
import { SetupWizardModal } from "./SetupWizardModal";
import { ToastContainer } from "./ToastContainer";
@@ -1106,14 +1105,6 @@ export function AppModals({
projectId={projectId}
/>
<MailboxModal
isOpen={modalManager.mailboxOpen}
onClose={modalManager.closeMailbox}
projectId={projectId}
addToast={addToast}
agents={modalManager.mailboxAgents}
/>
{modalManager.setupWizardOpen && (
<SetupWizardModal
onProjectRegistered={projectActions.handleSetupComplete}

View File

@@ -361,6 +361,72 @@ describe("Header", () => {
expect(skillsBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Chat View Toggle ─────────────────────────────────────────
it("renders chat view button in view toggle when onChangeView is provided", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const chatBtn = screen.getByTitle("Chat view");
expect(chatBtn).toBeDefined();
});
it("calls onChangeView with 'chat' when chat view button is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const chatBtn = screen.getByTitle("Chat view");
fireEvent.click(chatBtn);
expect(onChangeView).toHaveBeenCalledWith("chat");
});
it("marks chat view button as active when view is 'chat'", () => {
const onChangeView = vi.fn();
render(<Header view="chat" onChangeView={onChangeView} />);
const chatBtn = screen.getByTitle("Chat view");
expect(chatBtn.className).toContain("active");
expect(chatBtn.getAttribute("aria-pressed")).toBe("true");
});
it("does not mark chat view button as active when view is 'board'", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const chatBtn = screen.getByTitle("Chat view");
expect(chatBtn.className).not.toContain("active");
expect(chatBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Mailbox View Toggle ─────────────────────────────────────
it("renders mailbox view button in view toggle when onChangeView is provided", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const mailboxBtn = screen.getByTitle("Mailbox view");
expect(mailboxBtn).toBeDefined();
});
it("calls onChangeView with 'mailbox' when mailbox view button is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const mailboxBtn = screen.getByTitle("Mailbox view");
fireEvent.click(mailboxBtn);
expect(onChangeView).toHaveBeenCalledWith("mailbox");
});
it("marks mailbox view button as active when view is 'mailbox'", () => {
const onChangeView = vi.fn();
render(<Header view="mailbox" onChangeView={onChangeView} />);
const mailboxBtn = screen.getByTitle("Mailbox view");
expect(mailboxBtn.className).toContain("active");
expect(mailboxBtn.getAttribute("aria-pressed")).toBe("true");
});
it("does not mark mailbox view button as active when view is 'board'", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const mailboxBtn = screen.getByTitle("Mailbox view");
expect(mailboxBtn.className).not.toContain("active");
expect(mailboxBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Roadmaps View Toggle ───────────────────────────────────────
it("renders roadmaps view button in view toggle when onChangeView is provided", () => {
@@ -544,6 +610,42 @@ describe("Header", () => {
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
// ── Mailbox Button ────────────────────────────────────────────
it("renders mailbox button with correct title", () => {
const onOpenMailbox = vi.fn();
render(<Header onOpenMailbox={onOpenMailbox} />);
const btn = screen.getByTestId("header-mailbox-btn");
expect(btn).toBeDefined();
});
it("calls onOpenMailbox when mailbox button is clicked", () => {
const onOpenMailbox = vi.fn();
render(<Header onOpenMailbox={onOpenMailbox} />);
const btn = screen.getByTestId("header-mailbox-btn");
fireEvent.click(btn);
expect(onOpenMailbox).toHaveBeenCalledOnce();
});
it("shows unread badge when mailboxUnreadCount > 0", () => {
render(<Header mailboxUnreadCount={5} onOpenMailbox={vi.fn()} />);
const badge = screen.getByTestId("header-mailbox-badge");
expect(badge).toBeDefined();
expect(badge.textContent).toBe("5");
});
it("shows 9+ when unread count exceeds 9", () => {
render(<Header mailboxUnreadCount={15} onOpenMailbox={vi.fn()} />);
const badge = screen.getByTestId("header-mailbox-badge");
expect(badge.textContent).toBe("9+");
});
it("does not show badge when unread count is 0", () => {
render(<Header mailboxUnreadCount={0} onOpenMailbox={vi.fn()} />);
const badge = screen.queryByTestId("header-mailbox-badge");
expect(badge).toBeNull();
});
// ── Terminal Button ─────────────────────────────────────────────
it("renders terminal button with correct title", () => {

View File

@@ -0,0 +1,448 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { MailboxView } from "../MailboxView";
import * as apiModule from "../../api";
import type { Agent } from "../../api";
import type { Message } from "@fusion/core";
// Mock the API module
vi.mock("../../api", () => ({
fetchInbox: vi.fn(),
fetchOutbox: vi.fn(),
fetchUnreadCount: vi.fn(),
fetchAgentMailbox: vi.fn(),
markMessageRead: vi.fn(),
markAllMessagesRead: vi.fn(),
deleteMessage: vi.fn(),
fetchConversation: vi.fn(),
sendMessage: vi.fn(),
fetchAgents: vi.fn(),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
X: () => <span data-testid="icon-x">X</span>,
Mail: () => <span data-testid="icon-mail">Mail</span>,
Send: () => <span data-testid="icon-send">Send</span>,
Inbox: () => <span data-testid="icon-inbox">Inbox</span>,
Bot: () => <span data-testid="icon-bot">Bot</span>,
Trash2: () => <span data-testid="icon-trash">Trash</span>,
Check: () => <span data-testid="icon-check">Check</span>,
CheckCheck: () => <span data-testid="icon-checkcheck">CheckCheck</span>,
Loader2: ({ className }: { className?: string }) => (
<span data-testid="icon-loader" className={className}>Loader</span>
),
RefreshCw: () => <span data-testid="icon-refresh">Refresh</span>,
MessageSquare: () => <span data-testid="icon-message">Message</span>,
User: () => <span data-testid="icon-user">User</span>,
AlertCircle: () => <span data-testid="icon-alert">Alert</span>,
}));
const mockFetchInbox = vi.mocked(apiModule.fetchInbox);
const mockFetchOutbox = vi.mocked(apiModule.fetchOutbox);
const mockFetchUnreadCount = vi.mocked(apiModule.fetchUnreadCount);
const mockFetchAgentMailbox = vi.mocked(apiModule.fetchAgentMailbox);
const mockFetchAgents = vi.mocked(apiModule.fetchAgents);
const mockMarkMessageRead = vi.mocked(apiModule.markMessageRead);
const mockMarkAllMessagesRead = vi.mocked(apiModule.markAllMessagesRead);
const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
const mockAgents: Agent[] = [
{
id: "agent-001",
name: "Test Agent 1",
role: "executor",
state: "idle",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
{
id: "agent-002",
name: "Test Agent 2",
role: "triage",
state: "active",
taskId: "FN-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
];
const mockMessage: Message = {
id: "msg-001",
fromId: "agent-001",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Hello, this is a test message from the agent.",
type: "agent-to-user",
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mockReadMessage: Message = {
...mockMessage,
id: "msg-002",
read: true,
content: "This message has been read already.",
};
const defaultProps = {
addToast: vi.fn(),
};
describe("MailboxView", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 2 });
mockFetchAgents.mockResolvedValue(mockAgents);
});
it("renders the mailbox view", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render(<MailboxView {...defaultProps} />);
expect(screen.getByTestId("mailbox-view")).toBeDefined();
expect(screen.getByTestId("mailbox-tabs")).toBeDefined();
});
it("shows the Mailbox title with unread count badge", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-unread-badge")).toBeDefined();
});
});
it("renders all three tabs", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render(<MailboxView {...defaultProps} />);
expect(screen.getByTestId("mailbox-tab-inbox")).toBeDefined();
expect(screen.getByTestId("mailbox-tab-outbox")).toBeDefined();
expect(screen.getByTestId("mailbox-tab-agents")).toBeDefined();
});
it("shows inbox tab as active by default", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render(<MailboxView {...defaultProps} />);
const inboxTab = screen.getByTestId("mailbox-tab-inbox");
expect(inboxTab).toHaveClass("active");
});
it("loads inbox on mount", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage, mockReadMessage],
unreadCount: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(mockFetchInbox).toHaveBeenCalled();
});
});
it("shows inbox messages after loading", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage, mockReadMessage],
unreadCount: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-conversations")).toBeDefined();
});
});
it("groups messages by sender and shows unread count per group", async () => {
const secondMessage = { ...mockMessage, id: "msg-003", read: false };
mockFetchInbox.mockResolvedValue({
messages: [mockMessage, secondMessage], // Same sender, both unread
unreadCount: 2,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
// Should show one conversation group with 2 unread
const group = screen.getByTestId("mailbox-conversation-agent:agent-001");
expect(group).toBeDefined();
expect(screen.getByTestId("mailbox-unread-badge-agent:agent-001")).toBeDefined();
});
});
it("shows unread dot for unread messages", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-unread-badge-agent:agent-001")).toBeDefined();
});
});
it("does not show unread dot for read messages", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockReadMessage],
unreadCount: 0,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.queryByTestId("mailbox-unread-dot-msg-002")).toBeNull();
});
});
it("switches to outbox tab on click", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
mockFetchOutbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render(<MailboxView {...defaultProps} />);
const outboxTab = screen.getByTestId("mailbox-tab-outbox");
await act(async () => {
fireEvent.click(outboxTab);
});
expect(mockFetchOutbox).toHaveBeenCalled();
});
it("switches to agents tab on click", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render(<MailboxView {...defaultProps} />);
const agentsTab = screen.getByTestId("mailbox-tab-agents");
await act(async () => {
fireEvent.click(agentsTab);
});
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalled();
});
});
it("opens message detail when clicking a message", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
mockFetchConversation.mockResolvedValue([mockMessage]);
// Mock markMessageRead to return undefined (simulating no read update needed)
mockMarkMessageRead.mockResolvedValue(undefined);
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
});
});
it("marks message as read when opening unread message", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
mockFetchConversation.mockResolvedValue([mockMessage]);
const onUnreadCountChange = vi.fn();
render(<MailboxView {...defaultProps} onUnreadCountChange={onUnreadCountChange} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
});
await waitFor(() => {
expect(mockMarkMessageRead).toHaveBeenCalledWith("msg-001", undefined);
});
});
it("calls markAllMessagesRead when clicking mark all read", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
mockMarkAllMessagesRead.mockResolvedValue({ markedAsRead: 1 });
const onUnreadCountChange = vi.fn();
render(<MailboxView {...defaultProps} onUnreadCountChange={onUnreadCountChange} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-mark-all-read")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-mark-all-read"));
});
await waitFor(() => {
expect(mockMarkAllMessagesRead).toHaveBeenCalledWith(undefined);
expect(onUnreadCountChange).toHaveBeenCalledWith(0);
});
});
it("deletes message when clicking delete in detail view", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
mockDeleteMessage.mockResolvedValue(undefined);
mockFetchConversation.mockResolvedValue([mockMessage]);
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-delete"));
});
await waitFor(() => {
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001", undefined);
});
});
it("shows compose FAB in inbox tab", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-compose-fab")).toBeDefined();
});
});
it("does not show compose FAB in agents tab", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render(<MailboxView {...defaultProps} />);
const agentsTab = screen.getByTestId("mailbox-tab-agents");
await act(async () => {
fireEvent.click(agentsTab);
});
await waitFor(() => {
expect(screen.queryByTestId("mailbox-compose-fab")).toBeNull();
});
});
it("shows loading skeleton while loading", async () => {
mockFetchInbox.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-skeleton")).toBeDefined();
});
});
it("shows empty inbox state when no messages", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-inbox-empty")).toBeDefined();
});
});
it("passes projectId to API calls", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render(<MailboxView {...defaultProps} projectId="test-project" />);
await waitFor(() => {
expect(mockFetchInbox).toHaveBeenCalledWith(
expect.objectContaining({ limit: 50 }),
"test-project"
);
expect(mockFetchUnreadCount).toHaveBeenCalledWith("test-project");
});
});
it("calls onUnreadCountChange when unread count changes", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 5,
});
const onUnreadCountChange = vi.fn();
render(<MailboxView {...defaultProps} onUnreadCountChange={onUnreadCountChange} />);
await waitFor(() => {
expect(onUnreadCountChange).toHaveBeenCalledWith(5);
});
});
});

View File

@@ -56,7 +56,7 @@ describe("MobileNavBar", () => {
mockViewport("mobile");
});
it("renders eight tab buttons (board + list + agents + missions + chat + skills + roadmaps + more)", () => {
it("renders nine tab buttons (board + list + agents + missions + chat + mailbox + skills + roadmaps + more)", () => {
render(<MobileNavBar {...createDefaultProps()} />);
expect(screen.getByTestId("mobile-nav-tab-board")).toBeDefined();
@@ -64,11 +64,31 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-agents")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-missions")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-chat")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-mailbox")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-roadmaps")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
});
it("active tab is highlighted for mailbox", () => {
render(<MobileNavBar {...createDefaultProps()} view="mailbox" />);
expect(screen.getByTestId("mobile-nav-tab-mailbox").className).toContain("mobile-nav-tab--active");
});
it("mailbox tab calls onChangeView with 'mailbox'", () => {
const props = createDefaultProps();
render(<MobileNavBar {...props} view="board" />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-mailbox"));
expect(props.onChangeView).toHaveBeenCalledWith("mailbox");
});
it("shows mailbox unread badge when mailboxUnreadCount > 0", () => {
render(<MobileNavBar {...createDefaultProps()} mailboxUnreadCount={5} />);
const badge = screen.getByTestId("mobile-nav-tab-mailbox").querySelector(".mobile-nav-tab-badge");
expect(badge).toBeDefined();
expect(badge?.textContent).toBe("5");
});
it("active tab is highlighted for agents", () => {
render(<MobileNavBar {...createDefaultProps()} view="agents" />);

View File

@@ -2,15 +2,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { Task, TaskDetail } from "@fusion/core";
import { useModalManager } from "../useModalManager";
import * as api from "../../api";
vi.mock("../../api", () => ({
fetchUnreadCount: vi.fn(),
fetchAgents: vi.fn(),
}));
const mockFetchUnreadCount = vi.mocked(api.fetchUnreadCount);
const mockFetchAgents = vi.mocked(api.fetchAgents);
function createTaskDetail(id: string): TaskDetail {
return {
@@ -56,14 +47,6 @@ function createTask(id: string): Task {
describe("useModalManager", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 3 });
mockFetchAgents.mockResolvedValue([
{
id: "agent-1",
role: "executor",
state: "active",
},
] as never);
});
it("manages open/close state for basic modals", () => {
@@ -136,25 +119,6 @@ describe("useModalManager", () => {
expect(result.current.terminalInitialCommand).toBe("pnpm build");
});
it("loads unread count and agents when mailbox opens", async () => {
const { result } = renderHook(() =>
useModalManager({ projectId: "proj_1", planningSessions: [] }),
);
act(() => {
result.current.openMailbox();
});
expect(result.current.mailboxOpen).toBe(true);
expect(mockFetchUnreadCount).toHaveBeenCalledWith("proj_1");
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, "proj_1");
await waitFor(() => {
expect(result.current.mailboxUnreadCount).toBe(3);
expect(result.current.mailboxAgents).toHaveLength(1);
});
});
it("tracks detail task state and supports tab-specific opens", () => {
const task = createTaskDetail("FN-123");
const { result } = renderHook(() =>

View File

@@ -1,6 +1,5 @@
import { useCallback, useState } from "react";
import type { Task, TaskDetail } from "@fusion/core";
import { fetchAgents, fetchUnreadCount, type Agent } from "../api";
import type { SectionId } from "../components/SettingsModal";
import type { ToastType } from "./useToast";
@@ -42,9 +41,6 @@ export interface ModalManager {
filesOpen: boolean;
fileBrowserWorkspace: string;
activityLogOpen: boolean;
mailboxOpen: boolean;
mailboxUnreadCount: number;
mailboxAgents: Agent[];
gitManagerOpen: boolean;
workflowStepsOpen: boolean;
agentsOpen: boolean;
@@ -94,9 +90,6 @@ export interface ModalManager {
openActivityLog: () => void;
closeActivityLog: () => void;
openMailbox: () => void;
closeMailbox: () => void;
openGitManager: () => void;
closeGitManager: () => void;
@@ -150,9 +143,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const [filesOpen, setFilesOpen] = useState(false);
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
const [activityLogOpen, setActivityLogOpen] = useState(false);
const [mailboxOpen, setMailboxOpen] = useState(false);
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
const [mailboxAgents, setMailboxAgents] = useState<Agent[]>([]);
const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
const [agentsOpen, setAgentsOpen] = useState(false);
@@ -169,7 +159,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
terminalOpen ||
filesOpen ||
activityLogOpen ||
mailboxOpen ||
gitManagerOpen ||
workflowStepsOpen ||
scriptsOpen ||
@@ -269,23 +258,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const openActivityLog = useCallback(() => setActivityLogOpen(true), []);
const closeActivityLog = useCallback(() => setActivityLogOpen(false), []);
const openMailbox = useCallback(() => {
setMailboxOpen(true);
fetchUnreadCount(projectId)
.then((data) => {
setMailboxUnreadCount(data.unreadCount);
})
.catch(() => {});
fetchAgents(undefined, projectId)
.then((agents) => {
setMailboxAgents(agents);
})
.catch(() => {});
}, [projectId]);
const closeMailbox = useCallback(() => setMailboxOpen(false), []);
const openGitManager = useCallback(() => setGitManagerOpen(true), []);
const closeGitManager = useCallback(() => setGitManagerOpen(false), []);
@@ -349,9 +321,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
filesOpen,
fileBrowserWorkspace,
activityLogOpen,
mailboxOpen,
mailboxUnreadCount,
mailboxAgents,
gitManagerOpen,
workflowStepsOpen,
agentsOpen,
@@ -388,8 +357,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
setFileWorkspace,
openActivityLog,
closeActivityLog,
openMailbox,
closeMailbox,
openGitManager,
closeGitManager,
openWorkflowSteps,

View File

@@ -4,7 +4,7 @@ import type { ProjectInfo } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
export type ViewMode = "overview" | "project";
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills";
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox";
interface UseViewStateOptions {
projectsLoading: boolean;
@@ -48,7 +48,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
const [taskView, setTaskView] = useState<TaskView>(() => {
const saved = getScopedItem("kb-dashboard-task-view");
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat" || saved === "roadmaps" || saved === "skills") return saved as TaskView;
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat" || saved === "roadmaps" || saved === "skills" || saved === "mailbox") return saved as TaskView;
return "board";
});
@@ -58,7 +58,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
useEffect(() => {
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat" || saved === "roadmaps" || saved === "skills") {
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat" || saved === "roadmaps" || saved === "skills" || saved === "mailbox") {
setTaskView(saved as TaskView);
return;
}

View File

@@ -25923,6 +25923,49 @@ html .column.drag-over * {
margin-top: 6px;
}
/* Conversation grouping */
.mailbox-conversations {
display: flex;
flex-direction: column;
gap: 2px;
}
.mailbox-conversation-group {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.1s;
position: relative;
}
.mailbox-conversation-group:hover {
background: var(--card-hover);
}
.mailbox-conversation-group.unread {
background: color-mix(in srgb, var(--todo) 10%, transparent);
font-weight: 500;
}
.mailbox-group-unread-badge {
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
font-size: 11px;
font-weight: 600;
background: var(--todo);
color: #fff;
border-radius: 10px;
flex-shrink: 0;
margin-top: 6px;
}
/* Message detail view */
.mailbox-message-detail {
display: flex;
@@ -28088,6 +28131,22 @@ html .column.drag-over * {
white-space: nowrap;
}
.mobile-nav-tab-badge {
position: absolute;
top: 2px;
right: calc(50% - 12px);
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 9px;
font-weight: 600;
line-height: 16px;
text-align: center;
background: var(--color-error);
color: #fff;
border-radius: 8px;
}
/* === Mobile More Sheet (Bottom Drawer) === */
.mobile-more-sheet-backdrop {