feat(FN-3719): make Mail tab messages open detail pane
Merges a substantial batch of agent and desktop improvements: the Mail tab now opens messages in a detail pane with styled selectable rows (FN-3719, four steps), remote node discovery contracts are integrated into the dashboard (FN-3506), permanent-agent approval context is wired through executor an Fusion-Task-Id: FN-3719
This commit is contained in:
5
.changeset/fn-3719-agent-mail-click-loads-message.md
Normal file
5
.changeset/fn-3719-agent-mail-click-loads-message.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Agent Detail Mail tab: clicking a message now loads its full content and marks unread inbox messages as read.
|
||||||
@@ -217,7 +217,7 @@ The agents surface provides:
|
|||||||
- A compact **Controls** popup for secondary actions (state filter, Show system agents toggle, Import, and global Heartbeat Speed)
|
- A compact **Controls** popup for secondary actions (state filter, Show system agents toggle, Import, and global Heartbeat Speed)
|
||||||
- Agent import can also be launched from the selected **Agent Detail** header; this entry opens the import modal directly in the companies.sh browse flow so operators can discover and import packages without leaving the detail context
|
- Agent import can also be launched from the selected **Agent Detail** header; this entry opens the import modal directly in the companies.sh browse flow so operators can discover and import packages without leaving the detail context
|
||||||
- Detail/config panels
|
- Detail/config panels
|
||||||
- Agent Detail includes a **Mail** tab for read-only inspection of that agent’s inbox/outbox without marking messages read or mutating mailbox state
|
- Agent Detail includes a **Mail** tab for inspecting that agent’s inbox/outbox; selecting a message opens full details, and selecting an unread inbox message marks it read
|
||||||
- Split-view synchronization: successful saves and lifecycle actions from the right-side Agent Detail pane immediately refresh the left-side list/selection state (no wait for background polling)
|
- Split-view synchronization: successful saves and lifecycle actions from the right-side Agent Detail pane immediately refresh the left-side list/selection state (no wait for background polling)
|
||||||
- A per-agent **Token Usage** panel that summarizes cumulative token consumption for the currently displayed agents
|
- A per-agent **Token Usage** panel that summarizes cumulative token consumption for the currently displayed agents
|
||||||
- Run history
|
- Run history
|
||||||
|
|||||||
@@ -604,6 +604,68 @@
|
|||||||
padding: var(--space-md) 0;
|
padding: var(--space-md) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-message {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-message:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-message:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-message--selected {
|
||||||
|
background: color-mix(in srgb, var(--todo) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-back {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-detail-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-detail-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-detail-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: calc(var(--space-2xl) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-reply-context {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-detail-body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Runs Tab --- */
|
/* --- Runs Tab --- */
|
||||||
.runs-tab {
|
.runs-tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1678,6 +1740,15 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-detail {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mail-tab .agent-mail-tab-back {
|
||||||
|
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
|
}
|
||||||
|
|
||||||
/* Runs Tab mobile */
|
/* Runs Tab mobile */
|
||||||
.run-card {
|
.run-card {
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse } from "../api";
|
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse } from "../api";
|
||||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox } from "../api";
|
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead } from "../api";
|
||||||
import type { Agent } from "../api";
|
import type { Agent } from "../api";
|
||||||
import type { AgentLogEntry, Task, Message, ParticipantType } from "@fusion/core";
|
import type { AgentLogEntry, Task, Message, ParticipantType } from "@fusion/core";
|
||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
@@ -689,6 +689,8 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
mailbox={agentMailbox}
|
mailbox={agentMailbox}
|
||||||
isLoading={isLoadingMailbox}
|
isLoading={isLoadingMailbox}
|
||||||
error={mailboxError}
|
error={mailboxError}
|
||||||
|
projectId={projectId}
|
||||||
|
addToast={addToast}
|
||||||
onRefresh={() => void loadMailbox()}
|
onRefresh={() => void loadMailbox()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1201,21 +1203,59 @@ function MailTab({
|
|||||||
mailbox,
|
mailbox,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
projectId,
|
||||||
|
addToast,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
agent: AgentDetail;
|
agent: AgentDetail;
|
||||||
mailbox: AgentMailboxResponse | null;
|
mailbox: AgentMailboxResponse | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
projectId?: string;
|
||||||
|
addToast?: (message: string, type?: "success" | "error") => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [activeSubtab, setActiveSubtab] = useState<"inbox" | "outbox">("inbox");
|
const [activeSubtab, setActiveSubtab] = useState<"inbox" | "outbox">("inbox");
|
||||||
|
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null);
|
||||||
const messages = activeSubtab === "inbox" ? (mailbox?.inbox ?? []) : (mailbox?.outbox ?? []);
|
const messages = activeSubtab === "inbox" ? (mailbox?.inbox ?? []) : (mailbox?.outbox ?? []);
|
||||||
|
const selectedMessage = selectedMessageId ? messages.find((message) => message.id === selectedMessageId) ?? null : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedMessageId(null);
|
||||||
|
}, [activeSubtab, agent.id]);
|
||||||
|
|
||||||
|
const handleMessageClick = async (message: Message) => {
|
||||||
|
setSelectedMessageId(message.id);
|
||||||
|
|
||||||
|
if (activeSubtab !== "inbox" || message.read) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await markMessageRead(message.id, projectId);
|
||||||
|
onRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = `Failed to mark message as read: ${getErrorMessage(err)}`;
|
||||||
|
if (addToast) {
|
||||||
|
addToast(errorMessage, "error");
|
||||||
|
} else {
|
||||||
|
console.warn(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setSelectedMessageId(null);
|
||||||
|
onRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
const renderMessage = (message: Message) => (
|
const renderMessage = (message: Message) => (
|
||||||
<div
|
<button
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={cn("mailbox-item", activeSubtab === "inbox" && !message.read && "unread")}
|
type="button"
|
||||||
|
className={cn("mailbox-item", "agent-mail-tab-message", activeSubtab === "inbox" && !message.read && "unread", selectedMessageId === message.id && "agent-mail-tab-message--selected")}
|
||||||
|
onClick={() => void handleMessageClick(message)}
|
||||||
|
aria-pressed={selectedMessageId === message.id}
|
||||||
>
|
>
|
||||||
<div className="mailbox-item-avatar">
|
<div className="mailbox-item-avatar">
|
||||||
{(activeSubtab === "inbox" ? message.fromType : message.toType) === "agent" ? <Bot size={16} /> : <User size={16} />}
|
{(activeSubtab === "inbox" ? message.fromType : message.toType) === "agent" ? <Bot size={16} /> : <User size={16} />}
|
||||||
@@ -1232,14 +1272,14 @@ function MailTab({
|
|||||||
<div className="mailbox-item-preview">{message.content.slice(0, 80)}{message.content.length > 80 ? "…" : ""}</div>
|
<div className="mailbox-item-preview">{message.content.slice(0, 80)}{message.content.length > 80 ? "…" : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
{activeSubtab === "inbox" && !message.read ? <div className="mailbox-item-unread-dot" aria-label="Unread message" /> : null}
|
{activeSubtab === "inbox" && !message.read ? <div className="mailbox-item-unread-dot" aria-label="Unread message" /> : null}
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="agent-mail-tab">
|
<div className="agent-mail-tab">
|
||||||
<div className="agent-mail-tab-header">
|
<div className="agent-mail-tab-header">
|
||||||
<h3>{agent.name} Mail</h3>
|
<h3>{agent.name} Mail</h3>
|
||||||
<button className="btn btn-sm" onClick={onRefresh} disabled={isLoading}>
|
<button className="btn btn-sm" onClick={handleRefresh} disabled={isLoading}>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
@@ -1278,16 +1318,52 @@ function MailTab({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isLoading && !error ? (
|
{!isLoading && !error ? (
|
||||||
<div className="mailbox-list" data-testid="agent-detail-mail-list">
|
selectedMessage ? (
|
||||||
{messages.length === 0 ? (
|
<div className="agent-mail-tab-detail" data-testid="agent-detail-mail-message">
|
||||||
<div className="mailbox-empty" data-testid="agent-detail-mail-empty">
|
<button
|
||||||
{activeSubtab === "inbox" ? <InboxIcon size={32} /> : <Send size={32} />}
|
type="button"
|
||||||
<p>{activeSubtab === "inbox" ? "No received messages for this agent" : "No sent messages for this agent"}</p>
|
className="btn btn-sm agent-mail-tab-back"
|
||||||
|
data-testid="agent-detail-mail-back"
|
||||||
|
onClick={() => setSelectedMessageId(null)}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
Back to {activeSubtab === "inbox" ? "Inbox" : "Outbox"}
|
||||||
|
</button>
|
||||||
|
<div className="agent-mail-tab-detail-meta">
|
||||||
|
<div className="agent-mail-tab-detail-row">
|
||||||
|
<span className="agent-mail-tab-detail-label">From</span>
|
||||||
|
<span>{mailboxParticipantLabel(selectedMessage.fromId, selectedMessage.fromType)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="agent-mail-tab-detail-row">
|
||||||
|
<span className="agent-mail-tab-detail-label">To</span>
|
||||||
|
<span>{mailboxParticipantLabel(selectedMessage.toId, selectedMessage.toType)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="agent-mail-tab-detail-row">
|
||||||
|
<span className="agent-mail-tab-detail-label">Type</span>
|
||||||
|
<span>{selectedMessage.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="agent-mail-tab-detail-row">
|
||||||
|
<span className="agent-mail-tab-detail-label">Sent</span>
|
||||||
|
<span>{new Date(selectedMessage.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
{selectedMessage.metadata?.replyTo?.messageId ? (
|
||||||
|
<div className="agent-mail-tab-reply-context">↪ Replying to message {selectedMessage.metadata.replyTo.messageId}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="agent-mail-tab-detail-body">{selectedMessage.content}</div>
|
||||||
messages.map(renderMessage)
|
</div>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<div className="mailbox-list" data-testid="agent-detail-mail-list">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="mailbox-empty" data-testid="agent-detail-mail-empty">
|
||||||
|
{activeSubtab === "inbox" ? <InboxIcon size={32} /> : <Send size={32} />}
|
||||||
|
<p>{activeSubtab === "inbox" ? "No received messages for this agent" : "No sent messages for this agent"}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map(renderMessage)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ vi.mock("../../api", () => ({
|
|||||||
fetchAgentLogs: vi.fn(),
|
fetchAgentLogs: vi.fn(),
|
||||||
fetchAgentLogsWithMeta: vi.fn(),
|
fetchAgentLogsWithMeta: vi.fn(),
|
||||||
fetchAgentMailbox: vi.fn(),
|
fetchAgentMailbox: vi.fn(),
|
||||||
|
markMessageRead: vi.fn(),
|
||||||
fetchAgentRunLogs: vi.fn(),
|
fetchAgentRunLogs: vi.fn(),
|
||||||
fetchAgentChildren: vi.fn(),
|
fetchAgentChildren: vi.fn(),
|
||||||
fetchAgentRuns: vi.fn(),
|
fetchAgentRuns: vi.fn(),
|
||||||
@@ -160,7 +161,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
|||||||
useConfirm: () => ({ confirm: mockConfirm }),
|
useConfirm: () => ({ confirm: mockConfirm }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, fetchAgentMailbox, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchCompanies } from "../../api";
|
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, fetchAgentMailbox, markMessageRead, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchCompanies } from "../../api";
|
||||||
import { subscribeSse } from "../../sse-bus";
|
import { subscribeSse } from "../../sse-bus";
|
||||||
|
|
||||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||||
@@ -190,6 +191,7 @@ const mockFetchModels = vi.mocked(fetchModels);
|
|||||||
const mockFetchPluginRuntimes = vi.mocked(fetchPluginRuntimes);
|
const mockFetchPluginRuntimes = vi.mocked(fetchPluginRuntimes);
|
||||||
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
|
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
|
||||||
const mockFetchAgentMailbox = vi.mocked(fetchAgentMailbox);
|
const mockFetchAgentMailbox = vi.mocked(fetchAgentMailbox);
|
||||||
|
const mockMarkMessageRead = vi.mocked(markMessageRead);
|
||||||
const mockUpgradeAgentHeartbeatProcedure = vi.mocked(upgradeAgentHeartbeatProcedure);
|
const mockUpgradeAgentHeartbeatProcedure = vi.mocked(upgradeAgentHeartbeatProcedure);
|
||||||
const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
|
const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
|
||||||
const mockFetchCompanies = vi.mocked(fetchCompanies);
|
const mockFetchCompanies = vi.mocked(fetchCompanies);
|
||||||
@@ -267,6 +269,18 @@ describe("AgentDetailView", () => {
|
|||||||
inbox: [],
|
inbox: [],
|
||||||
outbox: [],
|
outbox: [],
|
||||||
});
|
});
|
||||||
|
mockMarkMessageRead.mockResolvedValue({
|
||||||
|
id: "msg-default",
|
||||||
|
fromId: "dashboard",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-001",
|
||||||
|
toType: "agent",
|
||||||
|
content: "",
|
||||||
|
type: "user-to-agent",
|
||||||
|
read: true,
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
} as any);
|
||||||
// Default: no budget limit configured
|
// Default: no budget limit configured
|
||||||
mockFetchAgentBudgetStatus.mockResolvedValue({
|
mockFetchAgentBudgetStatus.mockResolvedValue({
|
||||||
agentId: "agent-001",
|
agentId: "agent-001",
|
||||||
@@ -793,6 +807,130 @@ describe("AgentDetailView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens message detail and supports going back to list", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockFetchAgentMailbox.mockResolvedValue({
|
||||||
|
ownerId: "agent-001",
|
||||||
|
ownerType: "agent",
|
||||||
|
unreadCount: 1,
|
||||||
|
messages: [],
|
||||||
|
inbox: [
|
||||||
|
{
|
||||||
|
id: "msg-1",
|
||||||
|
fromId: "dashboard",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-001",
|
||||||
|
toType: "agent",
|
||||||
|
content: "First line\nSecond line with full body",
|
||||||
|
type: "user-to-agent",
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
metadata: { replyTo: { messageId: "msg-0" } },
|
||||||
|
read: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outbox: [],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("Mail"));
|
||||||
|
await user.click(await screen.findByRole("button", { name: /You/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("agent-detail-mail-message")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/First line\s*Second line with full body/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Replying to message msg-0/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId("agent-detail-mail-back"));
|
||||||
|
expect(await screen.findByTestId("agent-detail-mail-list")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks unread inbox messages as read and refreshes mailbox", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockFetchAgentMailbox.mockResolvedValue({
|
||||||
|
ownerId: "agent-001",
|
||||||
|
ownerType: "agent",
|
||||||
|
unreadCount: 1,
|
||||||
|
messages: [],
|
||||||
|
inbox: [
|
||||||
|
{
|
||||||
|
id: "msg-1",
|
||||||
|
fromId: "dashboard",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-001",
|
||||||
|
toType: "agent",
|
||||||
|
content: "Unread message",
|
||||||
|
type: "user-to-agent",
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outbox: [],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
projectId="proj-1"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("Mail"));
|
||||||
|
await user.click(await screen.findByRole("button", { name: /You/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMarkMessageRead).toHaveBeenCalledWith("msg-1", "proj-1");
|
||||||
|
expect(mockFetchAgentMailbox).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark already read inbox messages", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockFetchAgentMailbox.mockResolvedValue({
|
||||||
|
ownerId: "agent-001",
|
||||||
|
ownerType: "agent",
|
||||||
|
unreadCount: 0,
|
||||||
|
messages: [],
|
||||||
|
inbox: [
|
||||||
|
{
|
||||||
|
id: "msg-1",
|
||||||
|
fromId: "dashboard",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-001",
|
||||||
|
toType: "agent",
|
||||||
|
content: "Read message",
|
||||||
|
type: "user-to-agent",
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
read: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outbox: [],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("Mail"));
|
||||||
|
await user.click(await screen.findByRole("button", { name: /You/i }));
|
||||||
|
|
||||||
|
expect(mockMarkMessageRead).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders redesigned dashboard summary sections", async () => {
|
it("renders redesigned dashboard summary sections", async () => {
|
||||||
render(
|
render(
|
||||||
<AgentDetailView
|
<AgentDetailView
|
||||||
|
|||||||
Reference in New Issue
Block a user