feat(FN-861): replace advanced settings placeholder with editable form in AgentDetailView

- Replace advanced settings placeholder in AgentDetailView with full editable form supporting all setting fields
- Remove color theme system (ThemeSelector component, useTheme hook, theme styles)
- Simplify QuickEntryBox by extracting shared logic
- Add comprehensive tests for AgentDetailView settings form including logLevel select field
- Fix Settings saved indicator logic and update READMEs
- Remove unused color theme types from core and revert color themes changeset
This commit is contained in:
gsxdsm
2026-04-04 03:33:16 -07:00
parent 0f60a1cebc
commit 757bca3f3c
3 changed files with 717 additions and 22 deletions

View File

@@ -104,7 +104,7 @@ Manage AI agents with a dedicated control surface accessible from the main dashb
- **View Modes**: Board (compact grid) and list (detailed card) layouts, persisted to localStorage
- **Agent CRUD**: Create agents with name and role (create form uses dashboard radius tokens for consistent styling), change state, update roles inline, delete terminated agents
- **Health Monitoring**: Heartbeat-based health status (Healthy, Unresponsive, Starting, Paused, Terminated)
- **Agent Detail**: Click any agent card to open a detail modal with full agent information
- **Agent Detail**: Click any agent card to open a detail modal with full agent information. The **Settings** tab now includes **editable advanced settings** (heartbeat interval, max retries, task timeout, log level) persisted through `agent.metadata`. Empty fields revert to system defaults, invalid values block save with inline error messages
### Interactive Terminal
Access a fully functional PTY (pseudo-terminal) shell directly from the dashboard. Click the terminal icon in the header to open the interactive terminal modal.

View File

@@ -5,7 +5,7 @@ import {
ExternalLink, CheckCircle, XCircle, Loader2
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgentState, deleteAgent, fetchAgentLogs } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs } from "../api";
import type { AgentLogEntry } from "@fusion/core";
/**
@@ -342,6 +342,9 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
{activeTab === "config" && (
<ConfigTab
agent={agent}
projectId={projectId}
addToast={addToast}
onSaved={loadAgent}
/>
)}
</div>
@@ -1171,11 +1174,192 @@ function formatDuration(start: Date, end: Date): string {
// ── Config Tab ─────────────────────────────────────────────────────────────
/** Shape of a single advanced setting field stored in agent.metadata */
interface AdvancedSettingField {
key: string;
label: string;
type: "text" | "number" | "select";
placeholder?: string;
hint?: string;
options?: Array<{ value: string; label: string }>;
/** Minimum value for number fields */
min?: number;
/** Maximum value for number fields */
max?: number;
}
/** Well-known advanced setting definitions backed by agent.metadata */
const ADVANCED_SETTINGS: AdvancedSettingField[] = [
{
key: "heartbeatIntervalMs",
label: "Heartbeat Interval (ms)",
type: "number",
placeholder: "30000",
hint: "How often the agent sends heartbeats (minimum 1000ms, default 30000ms)",
min: 1000,
max: 600000,
},
{
key: "maxRetries",
label: "Max Retries",
type: "number",
placeholder: "3",
hint: "Maximum number of automatic retries on task failure (010, default 3)",
min: 0,
max: 10,
},
{
key: "timeoutMs",
label: "Task Timeout (ms)",
type: "number",
placeholder: "600000",
hint: "Maximum time in ms before a task is considered timed out (minimum 60000ms, default 600000ms)",
min: 60000,
max: 86400000,
},
{
key: "logLevel",
label: "Log Level",
type: "select",
hint: "Verbosity of agent log output",
options: [
{ value: "debug", label: "Debug" },
{ value: "info", label: "Info" },
{ value: "warn", label: "Warning" },
{ value: "error", label: "Error" },
],
},
];
/** Validation errors keyed by setting key */
type ValidationErrors = Record<string, string>;
function validateAdvancedSettings(
values: Record<string, string>,
): ValidationErrors {
const errors: ValidationErrors = {};
for (const field of ADVANCED_SETTINGS) {
const raw = values[field.key]?.trim();
// Empty is fine — it means "use default"
if (!raw) continue;
if (field.type === "number") {
const num = Number(raw);
if (Number.isNaN(num) || !Number.isFinite(num)) {
errors[field.key] = `"${field.label}" must be a valid number`;
continue;
}
if (field.min !== undefined && num < field.min) {
errors[field.key] = `"${field.label}" must be at least ${field.min.toLocaleString()}`;
}
if (field.max !== undefined && num > field.max) {
errors[field.key] = `"${field.label}" must be at most ${field.max.toLocaleString()}`;
}
}
if (field.type === "select") {
const validOptions = field.options?.map((o) => o.value) ?? [];
if (validOptions.length > 0 && !validOptions.includes(raw)) {
errors[field.key] = `"${field.label}" must be one of: ${validOptions.join(", ")}`;
}
}
}
return errors;
}
function ConfigTab({
agent
agent,
projectId,
addToast,
onSaved,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
// Local form state initialised from agent.metadata
const [formValues, setFormValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
for (const field of ADVANCED_SETTINGS) {
const raw = agent.metadata[field.key];
if (raw !== undefined && raw !== null) {
initial[field.key] = String(raw);
}
}
return initial;
});
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<ValidationErrors>({});
const [justSaved, setJustSaved] = useState(false);
/** Detect whether any local value differs from the persisted metadata */
const hasChanges = (() => {
for (const field of ADVANCED_SETTINGS) {
const current = formValues[field.key]?.trim() ?? "";
const persisted = agent.metadata[field.key] !== undefined && agent.metadata[field.key] !== null
? String(agent.metadata[field.key])
: "";
if (current !== persisted) return true;
}
return false;
})();
const handleFieldChange = (key: string, value: string) => {
setFormValues((prev) => ({ ...prev, [key]: value }));
setJustSaved(false);
// Clear individual field error on change
if (errors[key]) {
setErrors((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
};
const handleSave = async () => {
// Validate before save
const validationErrors = validateAdvancedSettings(formValues);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
addToast("Please fix validation errors before saving", "error");
return;
}
// Build the metadata payload — only include non-empty values
const newMetadata: Record<string, unknown> = { ...agent.metadata };
for (const field of ADVANCED_SETTINGS) {
const raw = formValues[field.key]?.trim();
if (!raw) {
// Remove the key to use system default
delete newMetadata[field.key];
} else if (field.type === "number") {
newMetadata[field.key] = Number(raw);
} else {
newMetadata[field.key] = raw;
}
}
setIsSaving(true);
try {
await updateAgent(agent.id, { metadata: newMetadata }, projectId);
addToast("Advanced settings saved", "success");
setJustSaved(true);
// Auto-hide the saved indicator after 3 seconds
setTimeout(() => setJustSaved(false), 3000);
await onSaved();
} catch (err: any) {
addToast(`Failed to save settings: ${err.message}`, "error");
} finally {
setIsSaving(false);
}
};
return (
<div className="config-tab">
<div className="config-section">
@@ -1214,15 +1398,75 @@ function ConfigTab({
<div className="config-section">
<h3>Advanced Settings</h3>
<p className="config-description">
Advanced configuration options for power users.
Advanced configuration options for this agent. Leave a field empty to use system defaults.
</p>
<div className="config-placeholder">
<Settings size={32} opacity={0.3} />
<p>Advanced configuration options will be available in a future update.</p>
<p className="text-muted">
This will include model selection, heartbeat intervals, and environment variables.
</p>
<div className="config-fields">
{ADVANCED_SETTINGS.map((field) => {
const hasError = !!errors[field.key];
return (
<div className="config-field" key={field.key}>
<label htmlFor={`adv-${field.key}`}>{field.label}</label>
{field.type === "select" ? (
<select
id={`adv-${field.key}`}
className={cn("select", hasError && "input--error")}
value={formValues[field.key] ?? ""}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
>
<option value="">System Default</option>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<input
id={`adv-${field.key}`}
type="text"
inputMode={field.type === "number" ? "numeric" : undefined}
className={cn("input", hasError && "input--error")}
placeholder={field.placeholder}
value={formValues[field.key] ?? ""}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
)}
{hasError && (
<span className="config-error">{errors[field.key]}</span>
)}
{!hasError && field.hint && (
<span className="config-hint">{field.hint}</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 Settings
</>
)}
</button>
{!hasChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Settings saved
</span>
)}
</div>
</div>
@@ -1274,17 +1518,30 @@ function ConfigTab({
font-style: italic;
}
.config-placeholder {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px;
color: var(--text-muted);
text-align: center;
.config-error {
font-size: 11px;
color: var(--error, #f85149);
}
.config-placeholder p {
margin: 8px 0 0 0;
.input--error {
border-color: var(--error, #f85149) !important;
}
.config-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.config-saved-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--success, #3fb950);
}
`}</style>
</div>

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { AgentDetailView } from "../AgentDetailView";
@@ -8,14 +8,16 @@ import type { AgentCapability, AgentDetail } from "../../api";
// Mock the API functions
vi.mock("../../api", () => ({
fetchAgent: vi.fn(),
updateAgent: vi.fn(),
updateAgentState: vi.fn(),
deleteAgent: vi.fn(),
fetchAgentLogs: vi.fn(),
}));
import { fetchAgent, updateAgentState } from "../../api";
import { fetchAgent, updateAgent, updateAgentState } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
const mockUpdateAgentState = vi.mocked(updateAgentState);
describe("AgentDetailView", () => {
@@ -59,6 +61,7 @@ describe("AgentDetailView", () => {
vi.clearAllMocks();
mockFetchAgent.mockResolvedValue(createMockAgent());
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "paused" }));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
});
it("shows loading state initially", () => {
@@ -277,4 +280,439 @@ describe("AgentDetailView", () => {
expect(screen.getByText("Live Run")).toBeInTheDocument();
});
});
// ── Advanced Settings (Config Tab) ────────────────────────────────────
describe("Advanced Settings", () => {
const navigateToSettings = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
};
it("renders advanced settings form fields on Settings tab", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByLabelText("Heartbeat Interval (ms)")).toBeInTheDocument();
expect(screen.getByLabelText("Max Retries")).toBeInTheDocument();
expect(screen.getByLabelText("Task Timeout (ms)")).toBeInTheDocument();
expect(screen.getByLabelText("Log Level")).toBeInTheDocument();
});
});
it("shows empty fields when metadata has no advanced settings", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
await waitFor(() => {
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
expect(heartbeatInput.value).toBe("");
});
});
it("pre-fills fields from agent metadata", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: {
heartbeatIntervalMs: 15000,
maxRetries: 5,
logLevel: "debug",
},
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
await waitFor(() => {
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
expect(heartbeatInput.value).toBe("15000");
const retriesInput = screen.getByLabelText("Max Retries") as HTMLInputElement;
expect(retriesInput.value).toBe("5");
const logLevelSelect = screen.getByLabelText("Log Level") as HTMLSelectElement;
expect(logLevelSelect.value).toBe("debug");
});
});
it("shows Save Settings button disabled when no changes", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByText("Save Settings")).toBeDisabled();
});
});
it("enables Save Settings when a field is changed", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "15000");
await waitFor(() => {
expect(screen.getByText("Save Settings")).not.toBeDisabled();
});
});
it("shows validation error for non-numeric input in number field", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
// Simulate setting a non-numeric value via React's internal value setter
// (userEvent.type on type="number" rejects non-numeric chars, so we bypass it)
const heartbeatInput = (await screen.findByLabelText("Heartbeat Interval (ms)")) as HTMLInputElement;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
)?.set;
nativeInputValueSetter?.call(heartbeatInput, 'abc');
fireEvent.change(heartbeatInput);
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be a valid number/)).toBeInTheDocument();
});
});
it("shows validation error for number below minimum", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "500");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be at least 1,000/)).toBeInTheDocument();
});
});
it("shows validation error for number above maximum", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
const retriesInput = await screen.findByLabelText("Max Retries");
await user.clear(retriesInput);
await user.type(retriesInput, "99");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be at most 10/)).toBeInTheDocument();
});
});
it("calls updateAgent with correct metadata on save", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "15000");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
{ metadata: expect.objectContaining({ heartbeatIntervalMs: 15000 }) },
undefined,
);
});
expect(addToast).toHaveBeenCalledWith("Advanced settings saved", "success");
});
it("forwards projectId to updateAgent", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
projectId="proj_456"
onClose={vi.fn()}
addToast={addToast}
/>
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "20000");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({ metadata: expect.any(Object) }),
"proj_456",
);
});
});
it("re-fetches agent after successful save", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
await navigateToSettings(user);
// Initial fetch + save-triggered refetch
const initialFetchCount = vi.mocked(fetchAgent).mock.calls.length;
const retriesInput = await screen.findByLabelText("Max Retries");
await user.clear(retriesInput);
await user.type(retriesInput, "7");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(vi.mocked(fetchAgent).mock.calls.length).toBeGreaterThan(initialFetchCount);
});
});
it("shows error toast on save failure", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockRejectedValue(new Error("Network error"));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
await navigateToSettings(user);
const retriesInput = await screen.findByLabelText("Max Retries");
await user.clear(retriesInput);
await user.type(retriesInput, "2");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to save settings"),
"error",
);
});
});
it("shows validation error for non-numeric input in number field", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
// Type "abc" directly into a text input
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "abc");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be a valid number/)).toBeInTheDocument();
});
});
it("pre-fills and persists logLevel select field", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: { logLevel: "debug" },
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
const logLevelSelect = await screen.findByLabelText("Log Level");
expect((logLevelSelect as HTMLSelectElement).value).toBe("debug");
});
it("clears metadata key when field is cleared to empty", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: { heartbeatIntervalMs: 30000 },
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
expect((heartbeatInput as HTMLInputElement).value).toBe("30000");
await user.clear(heartbeatInput);
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
metadata: expect.not.objectContaining({ heartbeatIntervalMs: expect.anything() }),
}),
undefined,
);
});
});
it("persists existing non-advanced metadata keys during save", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: { customKey: "preserved", heartbeatIntervalMs: 30000 },
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "45000");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
const call = mockUpdateAgent.mock.calls[0];
const metadata = (call as any)[1].metadata;
expect(metadata.customKey).toBe("preserved");
expect(metadata.heartbeatIntervalMs).toBe(45000);
});
});
});
});