feat(FN-3030): add heartbeat markdown viewer modal to AgentDetailView

Adds a heartbeat markdown viewer modal to the AgentDetailView (FN-3030) with styling, tests, and documentation for the feature, while also rendering multi-sample theme swatches in the ThemeSelector component (FN-3028).

Fusion-Task-Id: FN-3030
This commit is contained in:
Fusion
2026-04-30 18:26:15 -07:00
committed by gsxdsm
parent 5bd82c13e4
commit 718fd29251
5 changed files with 309 additions and 1 deletions

View File

@@ -743,6 +743,25 @@
color: var(--color-success);
}
.heartbeat-procedure-actions {
margin-top: var(--space-sm);
}
.heartbeat-procedure-viewer {
margin-top: var(--space-lg);
}
.heartbeat-procedure-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-left: auto;
}
.heartbeat-procedure-status--warning {
color: var(--color-warning);
}
/* === Agent Evaluation Ratings === */
.rating-summary-card {
@@ -1223,6 +1242,10 @@
width: 100%;
justify-content: center;
}
.heartbeat-procedure-status {
margin-left: 0;
}
}
@media (max-width: 480px) {

View File

@@ -2521,9 +2521,67 @@ function HeartbeatProcedureSection({
onSaved: () => Promise<void>;
}) {
const [isUpgrading, setIsUpgrading] = useState(false);
const [showFileViewer, setShowFileViewer] = useState(false);
const [isLoadingFile, setIsLoadingFile] = useState(false);
const [isSavingFile, setIsSavingFile] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [fileContent, setFileContent] = useState("");
const [fileContentDirty, setFileContentDirty] = useState(false);
const [fileLoadError, setFileLoadError] = useState<string | null>(null);
const [justSavedFile, setJustSavedFile] = useState(false);
const currentPath = agent.heartbeatProcedurePath?.trim();
const expectedDefaultPath = `.fusion/agents/${agent.id}/HEARTBEAT.md`;
const onDefault = currentPath === expectedDefaultPath;
const hasFilePath = Boolean(currentPath);
const loadHeartbeatFile = useCallback(async (path: string) => {
setIsLoadingFile(true);
setFileLoadError(null);
try {
const data = await fetchWorkspaceFileContent("project", path, projectId);
setFileContent(data.content);
setFileContentDirty(false);
} catch (err) {
const message = getErrorMessage(err);
setFileLoadError(message);
addToast(`Failed to load heartbeat procedure file: ${message}`, "error");
} finally {
setIsLoadingFile(false);
}
}, [addToast, projectId]);
useEffect(() => {
setShowFileViewer(false);
setShowPreview(false);
setFileContent("");
setFileContentDirty(false);
setFileLoadError(null);
setIsLoadingFile(false);
setIsSavingFile(false);
setJustSavedFile(false);
}, [agent.id, currentPath]);
const handleOpenViewer = async () => {
if (!currentPath) return;
setShowFileViewer(true);
await loadHeartbeatFile(currentPath);
};
const handleSaveFile = async () => {
if (!currentPath) return;
setIsSavingFile(true);
try {
await saveWorkspaceFileContent("project", currentPath, fileContent, projectId);
setFileContentDirty(false);
setJustSavedFile(true);
addToast("Heartbeat procedure file saved", "success");
setTimeout(() => setJustSavedFile(false), 3000);
} catch (err) {
addToast(`Failed to save heartbeat procedure file: ${getErrorMessage(err)}`, "error");
} finally {
setIsSavingFile(false);
}
};
const handleUpgrade = async () => {
setIsUpgrading(true);
@@ -2556,6 +2614,27 @@ function HeartbeatProcedureSection({
<span className="config-hint">
Current path: <code>{currentPath || "(none — using built-in default)"}</code>
</span>
{hasFilePath && (
<div className="heartbeat-procedure-actions">
<button
className="btn btn-sm"
onClick={() => void handleOpenViewer()}
disabled={isLoadingFile}
>
{isLoadingFile ? (
<>
<Loader2 size={16} className="animate-spin" />
Loading file
</>
) : (
<>
<FileText size={16} />
View Heartbeat Markdown
</>
)}
</button>
</div>
)}
</div>
<div className="config-field">
<button
@@ -2587,6 +2666,105 @@ function HeartbeatProcedureSection({
</span>
</div>
</div>
{showFileViewer && hasFilePath && currentPath && (
<div className="config-fields heartbeat-procedure-viewer">
<div className="config-field">
<label htmlFor="heartbeat-procedure-file-content">Heartbeat Procedure File</label>
<div className="agent-content-toolbar">
<div className="agent-content-mode-toggle">
<button
className={`btn btn-sm ${!showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(false)}
disabled={!showPreview}
aria-label="Heartbeat file edit mode"
>
<FileEdit size={14} />
Edit
</button>
<button
className={`btn btn-sm ${showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(true)}
disabled={showPreview}
aria-label="Heartbeat file preview mode"
>
<Eye size={14} />
Preview
</button>
</div>
{isLoadingFile && (
<span className="config-hint heartbeat-procedure-status">
<Loader2 size={12} className="animate-spin" />
Loading...
</span>
)}
{fileContentDirty && !isLoadingFile && (
<span className="config-hint heartbeat-procedure-status heartbeat-procedure-status--warning">
Unsaved changes
</span>
)}
</div>
{showPreview ? (
fileContent.trim() ? (
<div className="agent-content-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{fileContent}</ReactMarkdown>
</div>
) : (
<div className="agent-content-preview agent-content-placeholder">
No heartbeat procedure markdown content yet.
</div>
)
) : (
<textarea
id="heartbeat-procedure-file-content"
className="input"
rows={16}
value={fileContent}
readOnly={isLoadingFile}
placeholder="Heartbeat procedure markdown file content will appear here..."
onChange={(e) => {
setFileContent(e.target.value);
setFileContentDirty(true);
setJustSavedFile(false);
}}
/>
)}
{fileLoadError && (
<span className="config-error">Failed to load file: {fileLoadError}</span>
)}
<span className="config-hint">
This editor writes directly to <code>{currentPath}</code>.
</span>
</div>
{!showPreview && (
<div className="config-actions">
<button
className="btn btn-task-create"
disabled={!fileContentDirty || isSavingFile || isLoadingFile}
onClick={() => void handleSaveFile()}
>
{isSavingFile ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving…
</>
) : (
<>
<CheckCircle size={16} />
Save Heartbeat File
</>
)}
</button>
{!fileContentDirty && justSavedFile && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
File saved
</span>
)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -22,6 +22,7 @@ vi.mock("../../api", () => ({
fetchAgentRuns: vi.fn(),
fetchAgentRunDetail: vi.fn(),
startAgentRun: vi.fn(),
stopAgentRun: vi.fn(),
updateAgentInstructions: vi.fn(),
updateAgentSoul: vi.fn(),
updateAgentMemory: vi.fn(),
@@ -34,6 +35,7 @@ vi.mock("../../api", () => ({
fetchDiscoveredSkills: vi.fn(),
fetchModels: vi.fn(),
fetchPluginRuntimes: vi.fn(),
upgradeAgentHeartbeatProcedure: vi.fn(),
}));
vi.mock("../AgentLogViewer", () => ({
@@ -97,7 +99,7 @@ vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta } from "../../api";
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, upgradeAgentHeartbeatProcedure } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockFetchAgents = vi.mocked(fetchAgents);
@@ -121,6 +123,7 @@ const mockFetchDiscoveredSkills = vi.mocked(fetchDiscoveredSkills);
const mockFetchModels = vi.mocked(fetchModels);
const mockFetchPluginRuntimes = vi.mocked(fetchPluginRuntimes);
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
const mockUpgradeAgentHeartbeatProcedure = vi.mocked(upgradeAgentHeartbeatProcedure);
const MOCK_SKILLS = [
{ id: "skill-1", name: "Skill One", path: "/path/skill-1", relativePath: "skills/skill-1", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
@@ -214,6 +217,10 @@ describe("AgentDetailView", () => {
{ pluginId: "fusion-plugin-openclaw-runtime", runtimeId: "openclaw", name: "OpenClaw", description: "OpenClaw runtime", version: "1.0.0" },
{ pluginId: "fusion-plugin-hermes-runtime", runtimeId: "hermes", name: "Hermes", description: "Hermes runtime", version: "1.1.0" },
]);
mockUpgradeAgentHeartbeatProcedure.mockResolvedValue({
heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md",
procedureFileSeeded: true,
});
});
it("shows loading state initially", () => {
@@ -3856,4 +3863,85 @@ describe("AgentDetailView", () => {
});
});
});
describe("Heartbeat procedure file viewer", () => {
const openSettings = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
};
it("renders heartbeat markdown view action when heartbeatProcedurePath is set", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md",
}));
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await openSettings(user);
expect(screen.getByRole("button", { name: "View Heartbeat Markdown" })).toBeInTheDocument();
});
it("fetches and displays heartbeat file content from project workspace", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md",
}));
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "# Heartbeat\n\nDo checks", mtime: "2024-01-01T00:00:00.000Z", size: 20 });
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" projectId="proj-1" onClose={vi.fn()} addToast={vi.fn()} />);
await openSettings(user);
await user.click(screen.getByRole("button", { name: "View Heartbeat Markdown" }));
await waitFor(() => {
expect(mockFetchWorkspaceFileContent).toHaveBeenCalledWith("project", ".fusion/agents/agent-001/HEARTBEAT.md", "proj-1");
});
expect(screen.getByLabelText("Heartbeat Procedure File")).toHaveValue("# Heartbeat\n\nDo checks");
});
it("shows load error feedback when heartbeat file fetch fails", async () => {
const addToast = vi.fn();
mockFetchAgent.mockResolvedValue(createMockAgent({
heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md",
}));
mockFetchWorkspaceFileContent.mockRejectedValue(new Error("permission denied"));
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={addToast} />);
await openSettings(user);
await user.click(screen.getByRole("button", { name: "View Heartbeat Markdown" }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to load heartbeat procedure file: permission denied", "error");
});
expect(screen.getByText("Failed to load file: permission denied")).toBeInTheDocument();
});
it("refreshes to upgraded heartbeat path and supports immediate viewing", async () => {
mockFetchAgent
.mockResolvedValueOnce(createMockAgent({ heartbeatProcedurePath: undefined }))
.mockResolvedValueOnce(createMockAgent({ heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md" }));
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "# Seeded", mtime: "2024-01-01T00:00:00.000Z", size: 8 });
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" projectId="proj-2" onClose={vi.fn()} addToast={vi.fn()} />);
await openSettings(user);
await user.click(screen.getByRole("button", { name: "Upgrade agent to default heartbeat procedure file" }));
await waitFor(() => {
expect(mockUpgradeAgentHeartbeatProcedure).toHaveBeenCalledWith("agent-001", "proj-2");
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "View Heartbeat Markdown" })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "View Heartbeat Markdown" }));
await waitFor(() => {
expect(mockFetchWorkspaceFileContent).toHaveBeenCalledWith("project", ".fusion/agents/agent-001/HEARTBEAT.md", "proj-2");
});
});
});
});

View File

@@ -41,6 +41,7 @@ vi.mock("../../api", () => ({
cancelAgentGeneration: vi.fn(),
fetchAgentBudgetStatus: vi.fn(),
resetAgentBudget: vi.fn(),
upgradeAgentHeartbeatProcedure: vi.fn(),
}));
vi.mock("../AgentLogViewer", () => ({