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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ vi.mock("../../api", () => ({
|
||||
cancelAgentGeneration: vi.fn(),
|
||||
fetchAgentBudgetStatus: vi.fn(),
|
||||
resetAgentBudget: vi.fn(),
|
||||
upgradeAgentHeartbeatProcedure: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentLogViewer", () => ({
|
||||
|
||||
Reference in New Issue
Block a user