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:
@@ -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'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'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'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 && (
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user