feat(FN-3584): add memory file markdown preview to agent detail and log vie

This merge introduces a memory file markdown preview feature (FN-3584) with corresponding documentation, refines the AgentDetailView and AgentLogViewer components in the dashboard, and adds defensive collision handling for worktree operations during manual task moves (FN-3583).

Fusion-Task-Id: FN-3584
This commit is contained in:
Fusion
2026-05-06 07:45:56 -07:00
committed by gsxdsm
parent 9dd8445c96
commit 270823dcc9
9 changed files with 217 additions and 165 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix duplicate ntfy merge notifications by ensuring `ProjectEngine` uses a single `NotificationService` listener graph and passes that shared service into the `NtfyNotifier` compatibility shim.

View File

@@ -354,6 +354,8 @@ For long-form prompt authoring, **Soul**, **Agent Memory**, and **Inline Instruc
- Plain/edit mode and Markdown preview mode
- Fullscreen expand/collapse editing for long content (safe-area-aware on mobile)
In Agent Detail → **Agent Memory****Memory Files**, selected file content now also supports the same **Edit/Preview** markdown toggle. Preview renders the current in-memory draft (including unsaved edits), while save/edit controls remain gated by agent read-only state.
These controls are also available on the editable review step, so prompt content can be reviewed and refined with the same markdown and fullscreen behavior before submit.
### Final review edits (step 2)

View File

@@ -438,6 +438,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
- `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem)
- `Notifier` (`notifier.ts`) — legacy ntfy compatibility shim (`NtfyNotifier`) plus shared ntfy helpers
- Runtime ownership: `NtfyNotifier` no longer owns an independent task-lifecycle listener graph; `ProjectEngine` injects the canonical `NotificationService` instance so task lifecycle notifications (`task:moved`, `task:updated`, `task:merged`) are emitted through a single path.
- Merge dedup safety: `ProjectEngine.start()` is idempotent, so repeated start calls do not wire a second `NotificationService`/`NtfyNotifier` pair. A successful merge therefore emits exactly one canonical `merged` ntfy lifecycle notification per task.
- Compatibility scope: `NtfyNotifier` remains responsible for gridlock-only compatibility notifications (`notifyGridlock`) and legacy helper APIs.
- Legacy gridlock ntfy delivery is cooldown-throttled: first detection notifies immediately, subsequent detections are suppressed for 15 minutes (even if blocked-task membership changes), and the cooldown resets as soon as gridlock fully clears.
- `NotificationService` (`notification/notification-service.ts`) — provider lifecycle + event dispatch orchestration

View File

@@ -1957,6 +1957,7 @@ function MemoryTab({
const [isSaving, setIsSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [showFilePreview, setShowFilePreview] = useState(false);
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
const [memoryFilesLoading, setMemoryFilesLoading] = useState(false);
@@ -2027,6 +2028,7 @@ function MemoryTab({
setMemory(agent.memory ?? "");
setJustSaved(false);
setShowPreview(false);
setShowFilePreview(false);
setFileSwitchHint("");
setSelectedFileJustSaved(false);
void loadMemoryFiles();
@@ -2220,19 +2222,58 @@ function MemoryTab({
</div>
)}
<textarea
className="input config-textarea-mono config-textarea-top-spacing"
rows={14}
placeholder="Select a memory file to view and edit its content..."
value={selectedFileContent}
readOnly={isReadOnly || !selectedFilePath || selectedFileLoading}
onChange={(e) => {
setSelectedFileContent(e.target.value);
setSelectedFileDirty(true);
setSelectedFileJustSaved(false);
setFileSwitchHint("");
}}
/>
<div className="agent-content-toolbar config-textarea-top-spacing">
<div className="agent-content-mode-toggle">
{!isReadOnly && (
<button
className={`btn btn-sm ${!showFilePreview ? "btn-primary" : ""}`}
onClick={() => setShowFilePreview(false)}
disabled={!showFilePreview}
aria-label="Memory file edit mode"
>
<FileEdit size={14} />
Edit
</button>
)}
<button
className={`btn btn-sm ${showFilePreview ? "btn-primary" : ""}`}
onClick={() => setShowFilePreview(true)}
disabled={showFilePreview}
aria-label="Memory file preview mode"
>
<Eye size={14} />
Preview
</button>
</div>
</div>
{showFilePreview ? (
selectedFileContent.trim() ? (
<div className="agent-content-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{selectedFileContent}
</ReactMarkdown>
</div>
) : (
<div className="agent-content-preview agent-content-placeholder">
No memory file content yet. Switch to Edit mode to add content.
</div>
)
) : (
<textarea
className="input config-textarea-mono"
rows={14}
placeholder="Select a memory file to view and edit its content..."
value={selectedFileContent}
readOnly={isReadOnly || !selectedFilePath || selectedFileLoading}
onChange={(e) => {
setSelectedFileContent(e.target.value);
setSelectedFileDirty(true);
setSelectedFileJustSaved(false);
setFileSwitchHint("");
}}
/>
)}
{selectedFileLoading && (
<span className="config-hint config-hint--inline-loader">
@@ -2269,23 +2310,25 @@ function MemoryTab({
)}
</button>
)}
<button
className="btn"
disabled={!selectedFileDirty || savingSelectedFile || !selectedFilePath || isReadOnly}
onClick={() => void handleSaveSelectedMemoryFile()}
>
{savingSelectedFile ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving file
</>
) : (
<>
<CheckCircle size={16} />
Save Memory File
</>
)}
</button>
{!showFilePreview && (
<button
className="btn"
disabled={!selectedFileDirty || savingSelectedFile || !selectedFilePath || isReadOnly}
onClick={() => void handleSaveSelectedMemoryFile()}
>
{savingSelectedFile ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving file
</>
) : (
<>
<CheckCircle size={16} />
Save Memory File
</>
)}
</button>
)}
{!hasInlineChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />

View File

@@ -26,6 +26,9 @@ vi.mock("../../api", () => ({
updateAgentInstructions: vi.fn(),
updateAgentSoul: vi.fn(),
updateAgentMemory: vi.fn(),
fetchAgentMemoryFiles: vi.fn(),
fetchAgentMemoryFile: vi.fn(),
saveAgentMemoryFile: vi.fn(),
fetchAgentTasks: vi.fn(),
fetchChainOfCommand: vi.fn(),
fetchAgentBudgetStatus: vi.fn(),
@@ -119,7 +122,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, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, 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, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchCompanies } from "../../api";
import { subscribeSse } from "../../sse-bus";
const mockFetchAgent = vi.mocked(fetchAgent);
@@ -138,6 +141,9 @@ const mockResetAgentBudget = vi.mocked(resetAgentBudget);
const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions);
const mockUpdateAgentSoul = vi.mocked(updateAgentSoul);
const mockUpdateAgentMemory = vi.mocked(updateAgentMemory);
const mockFetchAgentMemoryFiles = vi.mocked(fetchAgentMemoryFiles);
const mockFetchAgentMemoryFile = vi.mocked(fetchAgentMemoryFile);
const mockSaveAgentMemoryFile = vi.mocked(saveAgentMemoryFile);
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
const mockFetchDiscoveredSkills = vi.mocked(fetchDiscoveredSkills);
@@ -231,6 +237,22 @@ describe("AgentDetailView", () => {
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 });
mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 });
mockUpdateAgentInstructions.mockResolvedValue({} as any);
mockFetchAgentMemoryFiles.mockResolvedValue({
files: [
{
path: ".fusion/agent-memory/agent-001/MEMORY.md",
label: "MEMORY.md",
layer: "long-term",
size: 12,
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
} as any);
mockFetchAgentMemoryFile.mockResolvedValue({
path: ".fusion/agent-memory/agent-001/MEMORY.md",
content: "",
} as any);
mockSaveAgentMemoryFile.mockResolvedValue({ success: true } as any);
// Default: return skills
mockFetchDiscoveredSkills.mockResolvedValue(MOCK_SKILLS);
mockFetchSkillContent.mockResolvedValue({ name: "Skill", skillMd: "# Skill", files: [] });
@@ -4248,55 +4270,26 @@ describe("AgentDetailView", () => {
it("renders Memory tab with textarea by default", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await waitFor(() => {
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
expect(screen.getByText("Edit")).toBeInTheDocument();
expect(screen.getByText("Preview")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Edit mode" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Preview mode" })).toBeInTheDocument();
});
});
it("toggles between edit and preview mode", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
memory: "# Agent Memory\n\n- Item 1\n- Item 2",
}));
mockFetchAgent.mockResolvedValue(createMockAgent({ memory: "# Agent Memory\n\n- Item 1\n- Item 2" }));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
// Default: edit mode
await waitFor(() => {
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
});
// Click Preview
await user.click(screen.getByText("Preview"));
await user.click(screen.getByRole("button", { name: "Preview mode" }));
await waitFor(() => {
expect(screen.queryByLabelText("Agent Memory")).not.toBeInTheDocument();
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
});
// Click Edit
await user.click(screen.getByText("Edit"));
await user.click(screen.getByRole("button", { name: "Edit mode" }));
await waitFor(() => {
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
});
@@ -4304,18 +4297,9 @@ describe("AgentDetailView", () => {
it("shows placeholder when memory is empty in preview mode", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await user.click(screen.getByText("Preview"));
await user.click(screen.getByRole("button", { name: "Preview mode" }));
await waitFor(() => {
expect(screen.getByText("No agent memory defined yet. Switch to Edit mode to add memory content.")).toBeInTheDocument();
});
@@ -4323,126 +4307,83 @@ describe("AgentDetailView", () => {
it("hides save button when in preview mode", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
expect(screen.getByText("Save Memory")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Preview mode" }));
await waitFor(() => expect(screen.queryByText("Save Memory")).not.toBeInTheDocument());
});
it("hides inline Edit button when agent is running", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running", memory: "This agent has memory." }));
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await waitFor(() => {
expect(screen.getByText("Save Memory")).toBeInTheDocument();
});
await user.click(screen.getByText("Preview"));
await waitFor(() => {
expect(screen.queryByText("Save Memory")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Preview mode" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit mode" })).not.toBeInTheDocument();
});
});
it("hides Edit button when agent is running", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
state: "running",
memory: "This agent has memory.",
}));
it("can switch inline memory to preview mode when agent is running", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running", memory: "Agent memory content" }));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await user.click(screen.getByRole("button", { name: "Preview mode" }));
await waitFor(() => expect(document.querySelector(".markdown-body")).toBeInTheDocument());
});
// Preview button should be visible, Edit button should be hidden
it("renders memory file preview markdown and toggles back to edit", async () => {
mockFetchAgentMemoryFile.mockResolvedValue({ path: ".fusion/agent-memory/agent-001/MEMORY.md", content: "# Heading\n\n- entry" } as any);
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await user.click(await screen.findByRole("button", { name: "Memory file preview mode" }));
await waitFor(() => {
expect(screen.getByText("Preview")).toBeInTheDocument();
// Edit button should not be in the DOM (not just disabled, hidden)
expect(screen.queryByRole("button", { name: /Edit/i })).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText("Select a memory file to view and edit its content...")).not.toBeInTheDocument();
expect(screen.getByText("Heading")).toBeInTheDocument();
expect(screen.queryByText("Save Memory File")).not.toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Memory file edit mode" }));
await waitFor(() => {
expect(screen.getByPlaceholderText("Select a memory file to view and edit its content...")).toBeInTheDocument();
});
});
it("shows Preview button but not Edit when agent is running", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
state: "running",
memory: "Agent memory content",
}));
it("shows memory file preview placeholder when selected file is empty", async () => {
mockFetchAgentMemoryFile.mockResolvedValue({ path: ".fusion/agent-memory/agent-001/MEMORY.md", content: "" } as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await user.click(await screen.findByRole("button", { name: "Memory file preview mode" }));
await waitFor(() => {
// Preview button is visible
const previewBtn = screen.getByRole("button", { name: /Preview/i });
expect(previewBtn).toBeInTheDocument();
// Edit button should be hidden
expect(screen.queryByRole("button", { name: /Edit/i })).not.toBeInTheDocument();
// Since Edit is hidden and default is edit mode, the textarea should still be visible
// but user needs to click Preview to see the markdown render
expect(screen.getByText("No memory file content yet. Switch to Edit mode to add content.")).toBeInTheDocument();
});
});
it("can switch to preview mode when agent is running", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
state: "running",
memory: "Agent memory content",
}));
it("hides memory file edit button and disables save button for running agents", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
// Click Preview button
await user.click(screen.getByText("Preview"));
await waitFor(() => {
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Memory file preview mode" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Memory file edit mode" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save Memory File" })).toBeDisabled();
});
});
it("calls updateAgentMemory when saving memory", async () => {
const addToast = vi.fn();
mockUpdateAgentMemory.mockResolvedValue({} as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={addToast} />);
await navigateToMemory(user);
const textarea = await screen.findByLabelText("Agent Memory");
await user.clear(textarea);
await user.type(textarea, "This is the agent's new memory");
await user.click(screen.getByText("Save Memory"));
await waitFor(() => {
expect(mockUpdateAgentMemory).toHaveBeenCalledWith("agent-001", "This is the agent's new memory", undefined);
expect(addToast).toHaveBeenCalledWith("Memory saved", "success");

View File

@@ -128,4 +128,36 @@ describe("NotificationService", () => {
expect(initSpy).not.toHaveBeenCalled();
initSpy.mockRestore();
});
it("duplicates merged dispatch when multiple NotificationService instances subscribe to the same store", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
const provider: NotificationProvider = {
getProviderId: () => "mock",
isEventSupported: () => true,
sendNotification,
};
const first = new NotificationService(store as any);
const second = new NotificationService(store as any);
first.registerProvider(provider);
second.registerProvider(provider);
await first.start();
await second.start();
// Confirms duplication is from duplicate listener graphs, not duplicate task:merged payloads.
store.emit("task:merged", {
task: task(),
branch: "fusion/fn-1",
merged: true,
worktreeRemoved: true,
branchDeleted: true,
});
await Promise.resolve();
expect(sendNotification).toHaveBeenCalledTimes(2);
await first.stop();
await second.stop();
});
});

View File

@@ -1093,7 +1093,7 @@ describe("NtfyNotifier", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("emits a single merged notification when notifier shares an already-started NotificationService", async () => {
it("emits a single merged notification when notifier shares the same already-started NotificationService (ProjectEngine wiring)", async () => {
const sharedService = new NotificationService(store, { projectId: "proj-1" });
await sharedService.start();

View File

@@ -288,6 +288,22 @@ describe("ProjectEngine notification ownership wiring", () => {
expect(mocks.notificationServiceStop).toHaveBeenCalledTimes(1);
expect(mocks.notifierStop).toHaveBeenCalledTimes(1);
});
it("does not recreate notification listeners on repeated start calls, preventing duplicate merged delivery", async () => {
const engine = createEngine({ skipNotifier: false, projectId: "proj_for_notifier" });
await engine.start();
await engine.start();
// Root cause guard: if ProjectEngine.start is called more than once, it should not
// wire a second NotificationService/NtfyNotifier pair for the same store.
expect(NotificationService).toHaveBeenCalledTimes(1);
expect(NtfyNotifier).toHaveBeenCalledTimes(1);
expect(mocks.notificationServiceStart).toHaveBeenCalledTimes(1);
expect(mocks.notifierStart).toHaveBeenCalledTimes(1);
await engine.stop();
});
});
describe("ProjectEngine PR monitoring wiring", () => {

View File

@@ -139,6 +139,7 @@ export interface ProjectEngineOptions {
*/
export class ProjectEngine {
private runtime: InProcessRuntime;
private started = false;
private prMonitor?: PrMonitor;
private prCommentHandler?: PrCommentHandler;
private notifier?: NtfyNotifier;
@@ -243,6 +244,10 @@ export class ProjectEngine {
* Start the engine: initialize the runtime and all auxiliary subsystems.
*/
async start(): Promise<void> {
if (this.started) {
return;
}
// 1. Start the core runtime (TaskStore, Scheduler, Executor, Triage, etc.)
await this.runtime.start();
@@ -422,6 +427,7 @@ export class ProjectEngine {
// 8. Start periodic merge retry sweep
this.scheduleMergeRetry(store);
this.started = true;
runtimeLog.log(`ProjectEngine started for ${this.config.projectId}`);
}
@@ -433,6 +439,10 @@ export class ProjectEngine {
* promptly without continuing git/verification work after shutdown starts.
*/
async stop(): Promise<void> {
if (!this.started) {
return;
}
this.shuttingDown = true;
// Stop merge retry timer
@@ -518,6 +528,8 @@ export class ProjectEngine {
// Stop the core runtime (Triage, Scheduler, Executor, etc.)
await this.runtime.stop();
this.started = false;
this.shuttingDown = false;
runtimeLog.log(`ProjectEngine stopped for ${this.config.projectId}`);
}