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:
@@ -6,13 +6,11 @@ import {
|
|||||||
Inbox as InboxIcon,
|
Inbox as InboxIcon,
|
||||||
Bot,
|
Bot,
|
||||||
Trash2,
|
Trash2,
|
||||||
Check,
|
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
User,
|
User,
|
||||||
AlertCircle,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Message, MessageType, ParticipantType } from "@fusion/core";
|
import type { Message, MessageType, ParticipantType } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
@@ -243,9 +241,20 @@ export function MailboxModal({
|
|||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
setComposeRecipient(null);
|
setComposeRecipient(null);
|
||||||
addToast?.("Message sent", "success");
|
addToast?.("Message sent", "success");
|
||||||
// Refresh outbox
|
// Refresh current tab
|
||||||
if (activeTab === "outbox") loadOutbox();
|
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(() => {
|
const handleComposeCancel = useCallback(() => {
|
||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
@@ -517,20 +526,30 @@ export function MailboxModal({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mailbox-agents-dropdown">
|
<div className="mailbox-agents-header">
|
||||||
<select
|
<div className="mailbox-agents-dropdown">
|
||||||
className="message-composer-select mailbox-agent-select"
|
<select
|
||||||
value={selectedAgentId ?? ""}
|
className="message-composer-select mailbox-agent-select"
|
||||||
onChange={(e) => setSelectedAgentId(e.target.value || null)}
|
value={selectedAgentId ?? ""}
|
||||||
data-testid="mailbox-agent-select"
|
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>
|
<MessageSquare size={14} />
|
||||||
{agents.map((agent) => (
|
<span>Compose</span>
|
||||||
<option key={agent.id} value={agent.id}>
|
</button>
|
||||||
{agent.name || agent.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mailbox-agents-content">
|
<div className="mailbox-agents-content">
|
||||||
{!selectedAgentId && (
|
{!selectedAgentId && (
|
||||||
@@ -582,7 +601,7 @@ export function MailboxModal({
|
|||||||
{!selectedMessage && !showComposer && activeTab !== "agents" && (
|
{!selectedMessage && !showComposer && activeTab !== "agents" && (
|
||||||
<button
|
<button
|
||||||
className="mailbox-compose-fab"
|
className="mailbox-compose-fab"
|
||||||
onClick={() => setShowComposer(true)}
|
onClick={handleOpenCompose}
|
||||||
title="Compose message"
|
title="Compose message"
|
||||||
data-testid="mailbox-compose-fab"
|
data-testid="mailbox-compose-fab"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 () => {
|
it("shows loading skeleton while loading", async () => {
|
||||||
mockFetchInbox.mockImplementation(() => new Promise(() => {})); // Never resolves
|
mockFetchInbox.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||||
render(<MailboxModal {...defaultProps} />);
|
render(<MailboxModal {...defaultProps} />);
|
||||||
|
|||||||
@@ -24770,6 +24770,17 @@ html .column.drag-over * {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mailbox-agents-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-compose-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-agent-select {
|
.mailbox-agent-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
|
|||||||
Reference in New Issue
Block a user