feat(FN-2002): add markdown preview modes to agent content tabs

- Add edit/preview toggles for Instructions, Soul, and Memory tabs in AgentDetailView
- Render markdown previews with ReactMarkdown and remark-gfm, including empty-state placeholders
- Hide save actions during preview mode and reset preview state when agent content changes
- Add CSS for preview toolbars and panes, plus comprehensive AgentDetailView tests for tab preview behavior
This commit is contained in:
Fusion
2026-04-17 18:55:12 -07:00
committed by gsxdsm
parent 8711c10922
commit fc106f6e6e
3 changed files with 795 additions and 114 deletions

View File

@@ -3,8 +3,10 @@ import {
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
Settings, FileText, ActivitySquare, X, Copy,
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
ChevronDown, ChevronRight, BarChart3, Star, BookOpen
ChevronDown, ChevronRight, BarChart3, Star, BookOpen, Eye, FileEdit
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels } from "../api";
import type { Agent } from "../api";
@@ -1559,10 +1561,12 @@ function SoulTab({
const [soul, setSoul] = useState(agent.soul ?? "");
const [isSaving, setIsSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
setSoul(agent.soul ?? "");
setJustSaved(false);
setShowPreview(false);
}, [agent.id, agent.soul]);
const hasChanges = soul !== (agent.soul ?? "");
@@ -1598,47 +1602,87 @@ function SoulTab({
<div className="config-fields">
<div className="config-field">
<label htmlFor="agent-soul">Agent Soul</label>
<textarea
id="agent-soul"
className="input"
rows={12}
placeholder="Describe this agent's personality, tone, and behavioral traits..."
value={soul}
onChange={(e) => {
setSoul(e.target.value);
setJustSaved(false);
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/>
<span className="config-hint">Defines the agent&apos;s character and identity. Max 10,000 characters.</span>
<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="Edit mode"
>
<FileEdit size={14} />
Edit
</button>
<button
className={`btn btn-sm ${showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(true)}
disabled={showPreview}
aria-label="Preview mode"
>
<Eye size={14} />
Preview
</button>
</div>
</div>
{showPreview ? (
soul.trim() ? (
<div className="agent-content-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{soul}
</ReactMarkdown>
</div>
) : (
<div className="agent-content-preview agent-content-placeholder">
No soul defined yet. Switch to Edit mode to define the agent&apos;s personality.
</div>
)
) : (
<textarea
id="agent-soul"
className="input"
rows={12}
placeholder="Describe this agent's personality, tone, and behavioral traits..."
value={soul}
onChange={(e) => {
setSoul(e.target.value);
setJustSaved(false);
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/>
)}
{!showPreview && (
<span className="config-hint">Defines the agent&apos;s character and identity. Max 10,000 characters.</span>
)}
</div>
</div>
<div className="config-actions">
<button
className="btn btn--primary"
disabled={!hasChanges || isSaving}
onClick={() => void handleSave()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Soul
</>
{!showPreview && (
<div className="config-actions">
<button
className="btn btn--primary"
disabled={!hasChanges || isSaving}
onClick={() => void handleSave()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Soul
</>
)}
</button>
{!hasChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Soul saved
</span>
)}
</button>
{!hasChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Soul saved
</span>
)}
</div>
</div>
)}
</div>
</div>
);
@@ -1658,10 +1702,12 @@ function MemoryTab({
const [memory, setMemory] = useState(agent.memory ?? "");
const [isSaving, setIsSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
setMemory(agent.memory ?? "");
setJustSaved(false);
setShowPreview(false);
}, [agent.id, agent.memory]);
const isReadOnly = agent.state === "running";
@@ -1703,48 +1749,90 @@ function MemoryTab({
<div className="config-fields">
<div className="config-field">
<label htmlFor="agent-memory">Agent Memory</label>
<textarea
id="agent-memory"
className="input"
rows={15}
placeholder="Durable preferences, operating habits, and context this agent should carry across tasks..."
value={memory}
readOnly={isReadOnly}
onChange={(e) => {
setMemory(e.target.value);
setJustSaved(false);
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/>
<span className="config-hint">This is injected as Agent Memory in the prompt and kept separate from workspace Project Memory. Max 50,000 characters.</span>
<div className="agent-content-toolbar">
<div className="agent-content-mode-toggle">
{!isReadOnly && (
<button
className={`btn btn-sm ${!showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(false)}
disabled={!showPreview}
aria-label="Edit mode"
>
<FileEdit size={14} />
Edit
</button>
)}
<button
className={`btn btn-sm ${showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(true)}
disabled={showPreview}
aria-label="Preview mode"
>
<Eye size={14} />
Preview
</button>
</div>
</div>
{showPreview ? (
memory.trim() ? (
<div className="agent-content-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{memory}
</ReactMarkdown>
</div>
) : (
<div className="agent-content-preview agent-content-placeholder">
No agent memory defined yet. Switch to Edit mode to add memory content.
</div>
)
) : (
<textarea
id="agent-memory"
className="input"
rows={15}
placeholder="Durable preferences, operating habits, and context this agent should carry across tasks..."
value={memory}
readOnly={isReadOnly}
onChange={(e) => {
setMemory(e.target.value);
setJustSaved(false);
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/>
)}
{!showPreview && (
<span className="config-hint">This is injected as Agent Memory in the prompt and kept separate from workspace Project Memory. Max 50,000 characters.</span>
)}
</div>
</div>
<div className="config-actions">
<button
className="btn btn--primary"
disabled={!hasChanges || isSaving || isReadOnly}
onClick={() => void handleSave()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Memory
</>
{!showPreview && (
<div className="config-actions">
<button
className="btn btn--primary"
disabled={!hasChanges || isSaving || isReadOnly}
onClick={() => void handleSave()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Memory
</>
)}
</button>
{!hasChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Memory saved
</span>
)}
</button>
{!hasChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Memory saved
</span>
)}
</div>
</div>
)}
</div>
</div>
);
@@ -1764,6 +1852,7 @@ function InstructionsTab({
// Inline instructions state
const [instructionsText, setInstructionsText] = useState(agent.instructionsText ?? "");
const [instructionsPath, setInstructionsPath] = useState(agent.instructionsPath ?? "");
const [showPreview, setShowPreview] = useState(false);
// File content state (when instructionsPath is set)
const [fileContent, setFileContent] = useState("");
@@ -1812,6 +1901,7 @@ function InstructionsTab({
setInstructionsPath(agent.instructionsPath ?? "");
setJustSaved(false);
setJustSavedFile(false);
setShowPreview(false);
}, [agent.id, agent.instructionsText, agent.instructionsPath]);
const hasInstructionsChanges = (() => {
@@ -1878,19 +1968,59 @@ function InstructionsTab({
<div className="config-fields">
<div className="config-field">
<label htmlFor="instructions-text">Inline Instructions</label>
<textarea
id="instructions-text"
className="input"
rows={10}
placeholder="Enter custom instructions to append to this agent's system prompt..."
value={instructionsText}
onChange={(e) => {
setInstructionsText(e.target.value);
setJustSaved(false);
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/>
<span className="config-hint">Markdown formatting supported. Max 50,000 characters.</span>
<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="Edit mode"
data-testid="instructions-edit-toggle"
>
<FileEdit size={14} />
Edit
</button>
<button
className={`btn btn-sm ${showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(true)}
disabled={showPreview}
aria-label="Preview mode"
data-testid="instructions-preview-toggle"
>
<Eye size={14} />
Preview
</button>
</div>
</div>
{showPreview ? (
instructionsText.trim() ? (
<div className="agent-content-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{instructionsText}
</ReactMarkdown>
</div>
) : (
<div className="agent-content-preview agent-content-placeholder">
No inline instructions defined yet. Switch to Edit mode to add instructions.
</div>
)
) : (
<textarea
id="instructions-text"
className="input"
rows={10}
placeholder="Enter custom instructions to append to this agent's system prompt..."
value={instructionsText}
onChange={(e) => {
setInstructionsText(e.target.value);
setJustSaved(false);
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/>
)}
{!showPreview && (
<span className="config-hint">Markdown formatting supported. Max 50,000 characters.</span>
)}
</div>
<div className="config-field">
@@ -1910,31 +2040,33 @@ function InstructionsTab({
</div>
</div>
<div className="config-actions">
<button
className="btn btn--primary"
disabled={!hasInstructionsChanges || isSaving}
onClick={() => void handleSaveInstructions()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Instructions
</>
{!showPreview && (
<div className="config-actions">
<button
className="btn btn--primary"
disabled={!hasInstructionsChanges || isSaving}
onClick={() => void handleSaveInstructions()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Instructions
</>
)}
</button>
{!hasInstructionsChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Instructions saved
</span>
)}
</button>
{!hasInstructionsChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Instructions saved
</span>
)}
</div>
</div>
)}
</div>
{hasFilePath && (

View File

@@ -86,7 +86,7 @@ vi.mock("../SkillMultiselect", () => ({
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchModels, fetchAgentLogsWithMeta } from "../../api";
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchModels, fetchAgentLogsWithMeta } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
@@ -100,6 +100,8 @@ const mockFetchChainOfCommand = vi.mocked(fetchChainOfCommand);
const mockFetchAgentBudgetStatus = vi.mocked(fetchAgentBudgetStatus);
const mockResetAgentBudget = vi.mocked(resetAgentBudget);
const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions);
const mockUpdateAgentSoul = vi.mocked(updateAgentSoul);
const mockUpdateAgentMemory = vi.mocked(updateAgentMemory);
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
const mockFetchDiscoveredSkills = vi.mocked(fetchDiscoveredSkills);
@@ -2632,6 +2634,514 @@ describe("AgentDetailView", () => {
);
});
});
it("toggles between edit and preview mode for inline instructions", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsText: "# Test\n\nThis is a test.",
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToInstructions(user);
// Default: edit mode should be active - verify textarea is present
await waitFor(() => {
expect(screen.getByLabelText("Inline Instructions")).toBeInTheDocument();
});
// Find and verify the toggle buttons exist
const previewBtn = screen.getByTestId("instructions-preview-toggle");
expect(previewBtn).toBeInTheDocument();
// Click Preview button
await user.click(previewBtn);
// After clicking, the textarea should be gone and preview should appear
await waitFor(() => {
expect(screen.queryByLabelText("Inline Instructions")).not.toBeInTheDocument();
});
// Check for markdown preview
const preview = document.querySelector(".markdown-body");
expect(preview).toBeInTheDocument();
// Click Edit button to go back
const editBtn = screen.getByTestId("instructions-edit-toggle");
await user.click(editBtn);
// Should be back in edit mode
await waitFor(() => {
expect(screen.getByLabelText("Inline Instructions")).toBeInTheDocument();
});
});
it("renders markdown content in preview mode for inline instructions", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsText: "# Test Instructions\n\nThis is **bold** and this is _italic_.",
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToInstructions(user);
// Click Preview button
await user.click(screen.getByTestId("instructions-preview-toggle"));
await waitFor(() => {
// Should render markdown elements
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Test Instructions");
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
});
});
it("shows placeholder when inline instructions are empty in preview mode", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToInstructions(user);
// Click Preview button when instructions are empty
await user.click(screen.getByTestId("instructions-preview-toggle"));
await waitFor(() => {
expect(screen.getByText("No inline instructions defined yet. Switch to Edit mode to add instructions.")).toBeInTheDocument();
});
});
it("hides save button when in preview mode for inline instructions", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToInstructions(user);
// Save button should be visible in edit mode
await waitFor(() => {
expect(screen.getByText("Save Instructions")).toBeInTheDocument();
});
// Click Preview button
await user.click(screen.getByTestId("instructions-preview-toggle"));
// Save button should be hidden
await waitFor(() => {
expect(screen.queryByText("Save Instructions")).not.toBeInTheDocument();
});
});
it("does not affect file path section when toggling inline instructions preview", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsPath: ".fusion/agents/test-agent.md",
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToInstructions(user);
// File path section should be visible
await waitFor(() => {
expect(screen.getByLabelText("Instructions File Path")).toBeInTheDocument();
});
// Toggle to preview mode
await user.click(screen.getByTestId("instructions-preview-toggle"));
// File path should still be visible
await waitFor(() => {
expect(screen.getByLabelText("Instructions File Path")).toBeInTheDocument();
});
// Toggle back to edit mode
await user.click(screen.getByTestId("instructions-edit-toggle"));
// File path should still be visible
await waitFor(() => {
expect(screen.getByLabelText("Instructions File Path")).toBeInTheDocument();
});
});
});
// ── Soul Tab ────────────────────────────────────────────────────────────────
describe("Soul Tab", () => {
const navigateToSoul = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
expect(screen.getByText("Soul")).toBeInTheDocument();
});
await user.click(screen.getByText("Soul"));
};
it("renders Soul tab with textarea by default", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSoul(user);
await waitFor(() => {
expect(screen.getByLabelText("Agent Soul")).toBeInTheDocument();
expect(screen.getByText("Edit")).toBeInTheDocument();
expect(screen.getByText("Preview")).toBeInTheDocument();
});
});
it("toggles between edit and preview mode", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
soul: "# Agent Soul\n\nThis agent is **helpful** and _creative_.",
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSoul(user);
// Default: edit mode
await waitFor(() => {
expect(screen.getByLabelText("Agent Soul")).toBeInTheDocument();
});
// Click Preview
await user.click(screen.getByText("Preview"));
await waitFor(() => {
expect(screen.queryByLabelText("Agent Soul")).not.toBeInTheDocument();
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Agent Soul");
});
// Click Edit
await user.click(screen.getByText("Edit"));
await waitFor(() => {
expect(screen.getByLabelText("Agent Soul")).toBeInTheDocument();
});
});
it("shows placeholder when soul is empty in preview mode", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSoul(user);
await user.click(screen.getByText("Preview"));
await waitFor(() => {
expect(screen.getByText("No soul defined yet. Switch to Edit mode to define the agent's personality.")).toBeInTheDocument();
});
});
it("hides save button when in preview mode", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSoul(user);
await waitFor(() => {
expect(screen.getByText("Save Soul")).toBeInTheDocument();
});
await user.click(screen.getByText("Preview"));
await waitFor(() => {
expect(screen.queryByText("Save Soul")).not.toBeInTheDocument();
});
});
it("calls updateAgentSoul when saving soul", async () => {
const addToast = vi.fn();
mockUpdateAgentSoul.mockResolvedValue({} as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
await navigateToSoul(user);
const textarea = await screen.findByLabelText("Agent Soul");
await user.clear(textarea);
await user.type(textarea, "This is the agent's new soul");
await user.click(screen.getByText("Save Soul"));
await waitFor(() => {
expect(mockUpdateAgentSoul).toHaveBeenCalledWith("agent-001", "This is the agent's new soul", undefined);
expect(addToast).toHaveBeenCalledWith("Soul saved", "success");
});
});
});
// ── Memory Tab ─────────────────────────────────────────────────────────────
describe("Memory Tab", () => {
const navigateToMemory = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
expect(screen.getByText("Agent Memory")).toBeInTheDocument();
});
await user.click(screen.getByText("Agent Memory"));
};
it("renders Memory tab with textarea by default", async () => {
const user = userEvent.setup();
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();
});
});
it("toggles between edit and preview mode", async () => {
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()}
/>
);
await navigateToMemory(user);
// Default: edit mode
await waitFor(() => {
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
});
// Click Preview
await user.click(screen.getByText("Preview"));
await waitFor(() => {
expect(screen.queryByLabelText("Agent Memory")).not.toBeInTheDocument();
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
});
// Click Edit
await user.click(screen.getByText("Edit"));
await waitFor(() => {
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
});
});
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()}
/>
);
await navigateToMemory(user);
await user.click(screen.getByText("Preview"));
await waitFor(() => {
expect(screen.getByText("No agent memory defined yet. Switch to Edit mode to add memory content.")).toBeInTheDocument();
});
});
it("hides save button when in preview mode", async () => {
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();
});
});
it("hides 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);
// Preview button should be visible, Edit button should be hidden
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();
});
});
it("shows Preview button but not Edit 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()}
/>
);
await navigateToMemory(user);
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
});
});
it("can switch 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()}
/>
);
await navigateToMemory(user);
// Click Preview button
await user.click(screen.getByText("Preview"));
await waitFor(() => {
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
});
});
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}
/>
);
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");
});
});
});
// ── Skills ─────────────────────────────────────────────────────────────────

View File

@@ -26421,6 +26421,45 @@ html .column.drag-over * {
margin: 0 0 var(--space-sm) 0;
}
/* === Agent Content Preview === */
.agent-content-toolbar {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) var(--space-lg);
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.agent-content-mode-toggle {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.agent-content-mode-toggle .btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.agent-content-preview {
overflow-y: auto;
padding: var(--space-lg);
min-height: 150px;
}
.agent-content-placeholder {
color: var(--text-muted);
text-align: center;
padding: var(--space-xl);
min-height: 150px;
display: flex;
align-items: center;
justify-content: center;
}
.config-description {
font-size: 14px;
color: var(--text-muted);