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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
691
packages/dashboard/app/components/MailboxView.tsx
Normal file
691
packages/dashboard/app/components/MailboxView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" : ""}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
448
packages/dashboard/app/components/__tests__/MailboxView.test.tsx
Normal file
448
packages/dashboard/app/components/__tests__/MailboxView.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" />);
|
||||
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user