feat(FN-1652): add compose action from Agents tab with deterministic state behavior

- Add compose button to Agents tab for composing messages to agents
- Compose pre-fills recipient when agent is selected, shows dropdown otherwise
- After send, user stays on Agents tab and selected agent is preserved
- Add 4 regression tests for new compose contract
- Add CSS for mailbox-agents-header layout
This commit is contained in:
gsxdsm
2026-04-12 16:34:56 -07:00
parent 73e51b2033
commit ef4bcb0264
3 changed files with 126 additions and 18 deletions

View File

@@ -6,13 +6,11 @@ import {
Inbox as InboxIcon,
Bot,
Trash2,
Check,
CheckCheck,
Loader2,
RefreshCw,
MessageSquare,
User,
AlertCircle,
} from "lucide-react";
import type { Message, MessageType, ParticipantType } from "@fusion/core";
import {
@@ -243,9 +241,20 @@ export function MailboxModal({
setShowComposer(false);
setComposeRecipient(null);
addToast?.("Message sent", "success");
// Refresh outbox
// Refresh current tab
if (activeTab === "outbox") loadOutbox();
}, [activeTab, loadOutbox, addToast]);
else if (activeTab === "agents" && selectedAgentId) loadAgentMailbox(selectedAgentId);
}, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, addToast]);
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);
@@ -517,20 +526,30 @@ export function MailboxModal({
</div>
) : (
<>
<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"
<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"
>
<option value="">Select an agent</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name || agent.id}
</option>
))}
</select>
<MessageSquare size={14} />
<span>Compose</span>
</button>
</div>
<div className="mailbox-agents-content">
{!selectedAgentId && (
@@ -582,7 +601,7 @@ export function MailboxModal({
{!selectedMessage && !showComposer && activeTab !== "agents" && (
<button
className="mailbox-compose-fab"
onClick={() => setShowComposer(true)}
onClick={handleOpenCompose}
title="Compose message"
data-testid="mailbox-compose-fab"
>

View File

@@ -344,6 +344,84 @@ describe("MailboxModal", () => {
});
});
it("shows compose button in Agents tab", async () => {
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-compose-btn")).toBeDefined();
});
});
it("compose opened from Agents tab without selected agent shows recipient select", async () => {
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-compose-btn")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mailbox-compose-btn"));
await waitFor(() => {
expect(screen.getByTestId("message-composer")).toBeDefined();
});
// Should show recipient dropdown (not pre-filled)
expect(screen.getByTestId("message-composer-recipient")).toBeDefined();
});
it("compose opened from Agents tab pre-fills selected agent recipient", async () => {
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 0,
messages: [],
});
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-agent-select")).toBeDefined();
});
// Select an agent
fireEvent.change(screen.getByTestId("mailbox-agent-select"), { target: { value: "agent-001" } });
await waitFor(() => {
expect(mockFetchAgentMailbox).toHaveBeenCalledWith("agent-001", undefined);
});
// Click compose
fireEvent.click(screen.getByTestId("mailbox-compose-btn"));
await waitFor(() => {
expect(screen.getByTestId("message-composer")).toBeDefined();
});
// Should show pre-filled recipient (not dropdown)
expect(screen.getByText("agent-001")).toBeDefined();
});
it("successful send from Agents tab keeps user on Agents tab and preserves selected agent", async () => {
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-agent-select")).toBeDefined();
});
// Select an agent
fireEvent.change(screen.getByTestId("mailbox-agent-select"), { target: { value: "agent-001" } });
await waitFor(() => {
expect(mockFetchAgentMailbox).toHaveBeenCalledWith("agent-001", undefined);
});
// Open compose (pre-filled)
fireEvent.click(screen.getByTestId("mailbox-compose-btn"));
await waitFor(() => {
expect(screen.getByTestId("message-composer")).toBeDefined();
});
// Type and send message
fireEvent.change(screen.getByTestId("message-composer-content"), {
target: { value: "Hello agent!" },
});
fireEvent.click(screen.getByTestId("message-composer-send"));
await waitFor(() => {
expect(screen.queryByTestId("message-composer")).toBeNull();
});
// Verify still on Agents tab and agent is still selected
expect(screen.getByTestId("mailbox-agents")).toBeDefined();
const select = screen.getByTestId("mailbox-agent-select") as HTMLSelectElement;
expect(select.value).toBe("agent-001");
});
it("shows loading skeleton while loading", async () => {
mockFetchInbox.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<MailboxModal {...defaultProps} />);