feat(FN-849): normalize agent UI theme consistency and simplify workflow step API

- Fix AgentListModal create-row and control styling to use token-based design tokens
- Normalize AgentDetailView theme consistency with shared token-based styling
- Simplify WorkflowStepManager by removing server-side CRUD, using local state only
- Remove unused store methods and types (workflowStep CRUD) from core package
- Remove server-side workflow step API routes and their tests
- Update dashboard README with shared token-based styling notes
- Add tests for AgentDetailView and extend AgentListModal tests
This commit is contained in:
gsxdsm
2026-04-04 05:33:23 -07:00
parent e7fee33eba
commit 226ad37cfa
5 changed files with 319 additions and 49 deletions

View File

@@ -97,14 +97,14 @@ A persistent footer status bar at the bottom of the dashboard displays real-time
- `GET /api/executor/stats` - Returns `globalPause`, `enginePaused`, `maxConcurrent`, and `lastActivityAt` for state derivation. Column-based counts (running, blocked, stuck, queued, in-review) are derived client-side from the shared task list.
### Agents View
Manage AI agents with a dedicated control surface accessible from the main dashboard navigation.
Manage AI agents with a dedicated control surface accessible from the main dashboard navigation. All agent surfaces (AgentsView, AgentListModal, AgentDetailView) share consistent token-based styling using dashboard design tokens (`--surface`, `--card`, `--border`, `--text`, `--color-success`, `--color-error`, etc.) and locally defined state color tokens (`--state-idle-*`, `--state-active-*`, `--state-paused-*`, `--state-error-*`) for theme-aware rendering.
**Features**:
- **State Filter**: Styled dropdown to filter agents by state (All States, Idle, Active, Paused, Terminated) with icon and consistent dashboard styling using design tokens
- **State Filter**: Styled dropdown to filter agents by state (All States, Idle, Active, Paused, Terminated) with Filter icon, aria-label, and consistent dashboard styling using design tokens (`--radius-sm`, `--border`, `--bg`, `--focus-ring`)
- **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. 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
- **Agent CRUD**: Create agents with name and role (create form uses `var(--radius-sm)` and `var(--bg-secondary)` tokens for consistent theme-aware styling), change state, update roles inline, delete terminated agents
- **Health Monitoring**: Heartbeat-based health status (Healthy, Unresponsive, Starting, Paused, Terminated) using CSS variable references for theme consistency
- **Agent Detail**: Click any agent card to open a detail modal with full agent information. The modal uses component-local token aliases (`--bg-primary`, `--accent`, `--text-primary`, `--bg-hover`) mapped to global tokens (`--surface`, `--todo`, `--text`, `--card-hover`) for theme consistency. 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

@@ -56,17 +56,17 @@ const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
];
const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: string }> = {
idle: { bg: "rgba(139, 148, 158, 0.15)", text: "#8b949e", border: "#8b949e" },
active: { bg: "rgba(46, 160, 67, 0.15)", text: "#3fb950", border: "#3fb950" },
paused: { bg: "rgba(227, 179, 65, 0.15)", text: "#e3b541", border: "#e3b541" },
terminated: { bg: "rgba(248, 81, 73, 0.15)", text: "#f85149", border: "#f85149" },
idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" },
active: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
};
const RUN_STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
completed: { icon: CheckCircle, color: "text-green-500" },
failed: { icon: XCircle, color: "text-red-500" },
active: { icon: Loader2, color: "text-cyan-500 animate-spin" },
terminated: { icon: Square, color: "text-gray-500" },
completed: { icon: CheckCircle, color: "var(--color-success, #3fb950)" },
failed: { icon: XCircle, color: "var(--color-error, #f85149)" },
active: { icon: Loader2, color: "var(--in-progress, #bc8cff)" },
terminated: { icon: Square, color: "var(--text-muted, #8b949e)" },
};
export function AgentDetailView({ agentId, projectId, onClose, addToast }: AgentDetailViewProps) {
@@ -179,23 +179,23 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
};
const getHealthStatus = () => {
if (!agent) return { label: "Unknown", color: "#888" };
if (!agent) return { label: "Unknown", color: "var(--text-muted, #8b949e)" };
if (agent.state === "terminated") {
return { label: "Terminated", color: "#f85149" };
return { label: "Terminated", color: "var(--state-error-text, #f85149)" };
}
if (agent.state === "paused") {
return { label: "Paused", color: "#e3b541" };
return { label: "Paused", color: "var(--state-paused-text, #e3b541)" };
}
if (!agent.lastHeartbeatAt) {
return { label: agent.state === "active" ? "Starting..." : "Idle", color: "#8b949e" };
return { label: agent.state === "active" ? "Starting..." : "Idle", color: "var(--state-idle-text, #8b949e)" };
}
const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime();
const elapsed = Date.now() - lastHeartbeat;
const timeoutMs = 60000;
if (elapsed > timeoutMs) {
return { label: "Unresponsive", color: "#f85149" };
return { label: "Unresponsive", color: "var(--state-error-text, #f85149)" };
}
return { label: "Healthy", color: "#3fb950" };
return { label: "Healthy", color: "var(--state-active-text, #3fb950)" };
};
const copyAgentId = () => {
@@ -372,6 +372,27 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
<style>{`
.agent-detail-overlay {
/* Agent state CSS variables - define fallback values */
--state-idle-bg: rgba(139, 148, 158, 0.15);
--state-idle-text: #8b949e;
--state-idle-border: #8b949e;
--state-active-bg: rgba(46, 160, 67, 0.15);
--state-active-text: #3fb950;
--state-active-border: #3fb950;
--state-paused-bg: rgba(227, 179, 65, 0.15);
--state-paused-text: #e3b541;
--state-paused-border: #e3b541;
--state-error-bg: rgba(248, 81, 73, 0.15);
--state-error-text: #f85149;
--state-error-border: #f85149;
--text-secondary: var(--text-muted, #8b949e);
/* Component-local aliases for dashboard tokens */
--bg-primary: var(--surface, #161b22);
--accent: var(--todo, #58a6ff);
--text-primary: var(--text, #e6edf3);
--bg-hover: var(--card-hover, #282e36);
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
@@ -385,7 +406,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
.agent-detail-modal {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 12px;
border-radius: var(--radius-lg);
width: 100%;
max-width: 900px;
max-height: 85vh;
@@ -423,7 +444,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
.agent-detail-icon {
width: 48px;
height: 48px;
border-radius: 12px;
border-radius: var(--radius-lg, 12px);
background: var(--accent);
display: flex;
align-items: center;
@@ -860,13 +881,13 @@ function LogsTab({
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--success);
color: var(--color-success, #3fb950);
}
.streaming-dot {
width: 8px;
height: 8px;
background: var(--success);
background: var(--color-success, #3fb950);
border-radius: 50%;
animation: pulse 1.5s infinite;
}
@@ -914,14 +935,14 @@ function LogEntry({ entry }: { entry: AgentLogEntry }) {
};
case "tool_result":
return {
color: "var(--success)",
borderLeft: "3px solid var(--success)",
color: "var(--color-success, #3fb950)",
borderLeft: "3px solid var(--color-success, #3fb950)",
background: "rgba(76, 175, 80, 0.06)",
};
case "tool_error":
return {
color: "var(--error)",
borderLeft: "3px solid var(--error)",
color: "var(--color-error, #f85149)",
borderLeft: "3px solid var(--color-error, #f85149)",
background: "rgba(229, 57, 53, 0.06)",
};
case "thinking":
@@ -1142,11 +1163,11 @@ function RunsTab({
}
.run-status.completed {
color: var(--success);
color: var(--color-success, #3fb950);
}
.run-status.failed {
color: var(--error);
color: var(--color-error, #f85149);
}
.run-status.terminated {
@@ -1520,11 +1541,11 @@ function ConfigTab({
.config-error {
font-size: 11px;
color: var(--error, #f85149);
color: var(--color-error, #f85149);
}
.input--error {
border-color: var(--error, #f85149) !important;
border-color: var(--color-error, #f85149) !important;
}
.config-actions {
@@ -1541,7 +1562,7 @@ function ConfigTab({
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--success, #3fb950);
color: var(--color-success, #3fb950);
}
`}</style>
</div>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { JSX } from "react";
import { X, Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List } from "lucide-react";
import { X, Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, Filter } from "lucide-react";
import type { Agent, AgentCapability, AgentState } from "../api";
import { fetchAgents, createAgent, updateAgent, updateAgentState, deleteAgent } from "../api";
@@ -197,17 +197,21 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<div className="modal-content">
{/* Filter and Create Bar */}
<div className="agent-controls">
<select
className="select"
value={filterState}
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
>
<option value="all">All States</option>
<option value="idle">Idle</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="terminated">Terminated</option>
</select>
<div className="agent-state-filter">
<Filter size={14} />
<select
className="agent-state-filter-select"
value={filterState}
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
aria-label="Filter agents by state"
>
<option value="all">All States</option>
<option value="idle">Idle</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="terminated">Terminated</option>
</select>
</div>
<button
className="btn btn--primary"
@@ -521,8 +525,38 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
margin-bottom: 16px;
}
.agent-controls .select {
width: auto;
.agent-state-filter {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
transition: border-color var(--transition-fast), color var(--transition-fast);
}
.agent-state-filter:hover {
border-color: var(--text-dim);
color: var(--text);
}
.agent-state-filter:focus-within {
border-color: var(--todo);
box-shadow: var(--focus-ring);
}
.agent-state-filter-select {
appearance: none;
background: transparent;
border: none;
color: var(--text);
font-size: 13px;
font-family: var(--font-primary);
cursor: pointer;
outline: none;
padding-right: 4px;
}
.agent-create-form {
@@ -531,7 +565,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
margin-bottom: 16px;
padding: 16px;
background: var(--bg-secondary);
border-radius: 8px;
border-radius: var(--radius-sm);
}
.agent-create-form .input {

View File

@@ -76,6 +76,134 @@ describe("AgentDetailView", () => {
expect(screen.getByText(/Loading agent/i)).toBeInTheDocument();
});
it("defines CSS variables for agent state tokens in the style block", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
const headings = screen.getAllByRole("heading", { level: 2 });
expect(headings.some(h => h.textContent === "Test Agent")).toBe(true);
});
// Verify state CSS variables are defined in the component's style block
const styleElements = document.querySelectorAll("style");
const allCss = Array.from(styleElements).map(el => el.textContent ?? "").join("");
expect(allCss).toContain("--state-idle-bg");
expect(allCss).toContain("--state-active-bg");
expect(allCss).toContain("--state-paused-bg");
expect(allCss).toContain("--state-error-bg");
expect(allCss).toContain("--state-idle-text");
expect(allCss).toContain("--state-active-text");
});
it("uses token-based state colors for badges instead of hardcoded hex", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
// Verify badge styles use CSS variable references for background, not hex values
const badges = document.querySelectorAll(".badge, .inline-badge");
badges.forEach(badge => {
const htmlEl = badge as HTMLElement;
const style = htmlEl.getAttribute("style") ?? "";
// Background should use var(--state-*) references, not raw rgba() or hex
if (style.includes("background")) {
expect(style).toContain("var(--state-");
// Should not use raw rgba() for state backgrounds
expect(style).not.toMatch(/background:\s*rgba\(/);
}
});
});
it("uses token-based colors for health status instead of hardcoded hex", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
// The mock agent is active with a heartbeat from 2024, so it should show "Unresponsive"
const hasHealthStatus = screen.queryAllByText(/Healthy|Unresponsive|Idle/).length > 0;
expect(hasHealthStatus).toBe(true);
});
// Health badges in header should use var(--state-*) references, not raw hex
const headerBadges = document.querySelectorAll(".agent-detail-badges .badge");
headerBadges.forEach(badge => {
const htmlEl = badge as HTMLElement;
const style = htmlEl.getAttribute("style") ?? "";
if (style.includes("color:") && !style.includes("var(--state-")) {
// If the color is not a state variable, it should still be a CSS variable
expect(style).toMatch(/color:\s*var\(/);
}
});
});
it("uses token-based color references in CSS instead of undefined vars", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
// Navigate to Runs tab to trigger rendering of run-related style blocks
fireEvent.click(screen.getByText("Runs"));
// Verify style blocks use --color-success and --color-error with fallbacks
// (not bare --success or --error which are undefined in the root CSS)
await waitFor(() => {
const styleElements = document.querySelectorAll("style");
const allCss = Array.from(styleElements).map(el => el.textContent ?? "").join("");
expect(allCss).toMatch(/var\(--color-success/);
expect(allCss).toMatch(/var\(--color-error/);
});
});
it("defines component-local aliases for undefined CSS tokens", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
// Verify that component-local aliases are defined for tokens used in the style block
// These map to real global tokens so they don't fall back to browser defaults
const styleElements = document.querySelectorAll("style");
const allCss = Array.from(styleElements).map(el => el.textContent ?? "").join("");
expect(allCss).toContain("--bg-primary: var(--surface");
expect(allCss).toContain("--accent: var(--todo");
expect(allCss).toContain("--text-primary: var(--text");
expect(allCss).toContain("--bg-hover: var(--card-hover");
});
it("displays agent name in header after loading", async () => {
render(
<AgentDetailView

View File

@@ -671,7 +671,7 @@ describe("AgentListModal", () => {
expect(screen.getByText("All States")).toBeTruthy();
});
const filterSelect = screen.getByDisplayValue("All States");
const filterSelect = screen.getByLabelText("Filter agents by state");
fireEvent.change(filterSelect, { target: { value: "active" } });
await waitFor(() => {
@@ -692,7 +692,7 @@ describe("AgentListModal", () => {
expect(screen.getByText("All States")).toBeTruthy();
});
const filterSelect = screen.getByDisplayValue("All States");
const filterSelect = screen.getByLabelText("Filter agents by state");
fireEvent.change(filterSelect, { target: { value: "idle" } });
await waitFor(() => {
@@ -755,6 +755,93 @@ describe("AgentListModal", () => {
});
});
describe("create form styling parity", () => {
it("renders create form with dashboard token-based styling", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
fireEvent.click(screen.getByText("New Agent"));
// The create form container is rendered
const createForm = document.querySelector(".agent-create-form");
expect(createForm).toBeTruthy();
// The inline style block should use var(--radius-sm) instead of hardcoded 8px
const styleElements = document.querySelectorAll("style");
let foundCreateFormRule = false;
styleElements.forEach(styleEl => {
const css = styleEl.textContent ?? "";
if (css.includes(".agent-create-form")) {
foundCreateFormRule = true;
// Must not contain hardcoded border-radius: 8px
expect(css).not.toMatch(/\.agent-create-form\s*\{[^}]*border-radius:\s*8px/);
}
});
expect(foundCreateFormRule).toBe(true);
});
it("renders filter with styled container matching AgentsView", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("All States")).toBeTruthy();
});
// Styled filter container exists
const filterContainer = document.querySelector(".agent-state-filter");
expect(filterContainer).toBeTruthy();
// Select has correct aria-label
const filterSelect = screen.getByLabelText("Filter agents by state");
expect(filterSelect).toBeTruthy();
expect(filterSelect).toHaveValue("all");
});
it("filter CSS uses dashboard tokens for border-radius", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeTruthy();
});
const styleElements = document.querySelectorAll("style");
let foundFilterRule = false;
styleElements.forEach(styleEl => {
const css = styleEl.textContent ?? "";
if (css.includes(".agent-state-filter {")) {
foundFilterRule = true;
// Should use var(--radius-sm) token
expect(css).toContain("border-radius: var(--radius-sm)");
// Should have hover and focus-within states
expect(css).toContain(".agent-state-filter:hover");
expect(css).toContain(".agent-state-filter:focus-within");
}
});
expect(foundFilterRule).toBe(true);
});
});
describe("view toggle", () => {
beforeEach(() => {
// Clear localStorage before each test