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:
Fusion
2026-05-07 19:59:57 -07:00
committed by gsxdsm
parent 6f3173abe7
commit 5514d3e25c
5 changed files with 306 additions and 16 deletions

View 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.

View File

@@ -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 agents inbox/outbox without marking messages read or mutating mailbox state - Agent Detail includes a **Mail** tab for inspecting that agents 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

View File

@@ -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);

View File

@@ -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>
); );

View File

@@ -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