diff --git a/docs/agents.md b/docs/agents.md index b8ca05efb..2b0ed5b64 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -196,6 +196,24 @@ There are two ways to provide custom instructions: - The editor has an **Unsaved changes** indicator when file content is modified - File saves are independent from instruction metadata saves +## Heartbeat Procedure File Access (Agent Detail Modal) + +The **Settings** tab in the Agent Detail modal includes a **Heartbeat Procedure** section with an in-modal markdown file viewer/editor. + +### How it works + +1. The section shows the current `heartbeatProcedurePath`. +2. When a path exists, use **View Heartbeat Markdown** to load and inspect that file without leaving the modal. +3. The editor supports both **Edit** and **Preview** modes, with an unsaved-changes indicator and dedicated save action. +4. Reads/writes are scoped through the workspace file APIs with `projectId` awareness in multi-project mode. + +### Relation to upgrade flow + +- **Upgrade to Default Heartbeat Procedure** still sets `heartbeatProcedurePath` to: + - `.fusion/agents/{agent.id}/HEARTBEAT.md` +- If the default file does not exist yet, the backend seeds it from the built-in template. +- After upgrade completes and the agent refreshes, operators can immediately open the seeded per-agent `HEARTBEAT.md` from the same modal section. + ## New Agent Presets (Dashboard UI) The New Agent dialog keeps the existing 3-step flow, and step 0 is split into two tabs: diff --git a/packages/dashboard/app/components/AgentDetailView.css b/packages/dashboard/app/components/AgentDetailView.css index fe7cc953e..306c9979c 100644 --- a/packages/dashboard/app/components/AgentDetailView.css +++ b/packages/dashboard/app/components/AgentDetailView.css @@ -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) { diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index a58d39399..967e115a3 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -2521,9 +2521,67 @@ function HeartbeatProcedureSection({ onSaved: () => Promise; }) { 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(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({ Current path: {currentPath || "(none — using built-in default)"} + {hasFilePath && ( +
+ +
+ )}
+ + {showFileViewer && hasFilePath && currentPath && ( +
+
+ +
+
+ + +
+ {isLoadingFile && ( + + + Loading... + + )} + {fileContentDirty && !isLoadingFile && ( + + Unsaved changes + + )} +
+ {showPreview ? ( + fileContent.trim() ? ( +
+ {fileContent} +
+ ) : ( +
+ No heartbeat procedure markdown content yet. +
+ ) + ) : ( +