feat(FN-1490): merge fusion/fn-1490
This commit is contained in:
@@ -231,7 +231,12 @@ 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's text input and role/type select both use tokenized styling — `var(--surface)`, `var(--text)`, `var(--border)`, `var(--radius-sm)`, `var(--focus-ring)` — for consistent theme-aware rendering across all color themes and light/dark modes), change state, update roles inline, delete idle and terminated agents (active and paused agents must be stopped/terminated first)
|
||||
- **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
|
||||
- **Agent Detail**: Click any agent card to open a detail modal with full agent information. The modal features a compact header with clear visual hierarchy:
|
||||
- **Identity area** (left): Agent icon, name, and state/health badges
|
||||
- **Lifecycle controls** (center): Compact action buttons for state transitions (Start, Pause, Resume, Retry, Stop, Delete)
|
||||
- **Utility actions** (right): Refresh and Close buttons
|
||||
- The compact layout reduces vertical footprint while maintaining all agent-state actions
|
||||
- The **Settings** tab 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.
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("Agent CSS classes", () => {
|
||||
expect(hasClass(".btn--primary")).toBe(true);
|
||||
expect(hasClass(".btn--danger")).toBe(true);
|
||||
expect(hasClass(".btn--warning")).toBe(true);
|
||||
expect(hasClass(".btn--compact")).toBe(true);
|
||||
});
|
||||
|
||||
// Verify badge base class
|
||||
@@ -109,6 +110,10 @@ describe("Agent CSS classes", () => {
|
||||
expect(hasClass(".agent-detail-info")).toBe(true);
|
||||
expect(hasClass(".agent-detail-badges")).toBe(true);
|
||||
expect(hasClass(".agent-detail-actions")).toBe(true);
|
||||
// Redesigned compact header structure
|
||||
expect(hasClass(".agent-detail-identity")).toBe(true);
|
||||
expect(hasClass(".agent-detail-controls")).toBe(true);
|
||||
expect(hasClass(".agent-detail-utility-actions")).toBe(true);
|
||||
expect(hasClass(".agent-detail-tabs")).toBe(true);
|
||||
expect(hasClass(".agent-detail-tab")).toBe(true);
|
||||
expect(hasClass(".agent-detail-content")).toBe(true);
|
||||
|
||||
@@ -254,9 +254,10 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<div className="agent-detail-modal">
|
||||
{/* Header */}
|
||||
<div className="agent-detail-header">
|
||||
<div className="agent-detail-title">
|
||||
{/* Identity area: icon + name + badges */}
|
||||
<div className="agent-detail-identity">
|
||||
<div className="agent-detail-icon">
|
||||
<Bot size={24} />
|
||||
<Bot size={20} />
|
||||
</div>
|
||||
<div className="agent-detail-info">
|
||||
<h2>{agent.name}</h2>
|
||||
@@ -274,82 +275,86 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agent-detail-actions">
|
||||
|
||||
{/* Lifecycle controls: compact action buttons */}
|
||||
<div className="agent-detail-controls">
|
||||
{/* State-dependent action buttons */}
|
||||
{agent.state === "idle" && (
|
||||
<>
|
||||
<button className="btn btn--primary" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={16} />
|
||||
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={14} />
|
||||
Start
|
||||
</button>
|
||||
<button className="btn btn--danger" onClick={handleDelete}>
|
||||
<Trash2 size={16} />
|
||||
<button className="btn btn--danger btn--compact" onClick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "active" && (
|
||||
<>
|
||||
<button className="btn" onClick={() => void handleStateChange("paused")}>
|
||||
<Pause size={16} />
|
||||
<button className="btn btn--compact" onClick={() => void handleStateChange("paused")}>
|
||||
<Pause size={14} />
|
||||
Pause
|
||||
</button>
|
||||
<button className="btn btn--danger" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={16} />
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "paused" && (
|
||||
<>
|
||||
<button className="btn btn--primary" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={16} />
|
||||
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={14} />
|
||||
Resume
|
||||
</button>
|
||||
<button className="btn btn--danger" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={16} />
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "running" && (
|
||||
<>
|
||||
<button className="btn" onClick={() => void handleStateChange("paused")}>
|
||||
<Pause size={16} />
|
||||
<button className="btn btn--compact" onClick={() => void handleStateChange("paused")}>
|
||||
<Pause size={14} />
|
||||
Pause
|
||||
</button>
|
||||
<button className="btn btn--danger" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={16} />
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "error" && (
|
||||
<>
|
||||
<button className="btn btn--primary" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={16} />
|
||||
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={14} />
|
||||
Retry
|
||||
</button>
|
||||
<button className="btn btn--danger" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={16} />
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<>
|
||||
<button className="btn btn--primary" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={16} />
|
||||
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={14} />
|
||||
Start
|
||||
</button>
|
||||
<button className="btn btn--danger" onClick={handleDelete}>
|
||||
<Trash2 size={16} />
|
||||
<button className="btn btn--danger btn--compact" onClick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Utility actions: refresh + close */}
|
||||
<div className="agent-detail-utility-actions">
|
||||
<button className="btn-icon" onClick={() => void loadAgent()} title="Refresh">
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
|
||||
@@ -529,6 +529,76 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Pause and Stop buttons for running agent", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Pause")).toBeInTheDocument();
|
||||
expect(screen.getByText("Stop")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Retry and Stop buttons for error agent", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "error" }));
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Retry")).toBeInTheDocument();
|
||||
expect(screen.getByText("Stop")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders lifecycle controls in compact layout with agent-detail-controls container", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify lifecycle controls are in the compact controls container
|
||||
const controlsContainer = document.querySelector(".agent-detail-controls");
|
||||
expect(controlsContainer).toBeTruthy();
|
||||
expect(controlsContainer?.querySelector(".btn--compact")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders utility actions in separate container", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify utility actions are in the separate utility container
|
||||
const utilityContainer = document.querySelector(".agent-detail-utility-actions");
|
||||
expect(utilityContainer).toBeTruthy();
|
||||
// Refresh and Close buttons should be in utility container
|
||||
expect(utilityContainer?.querySelector('[title="Refresh"]')).toBeTruthy();
|
||||
expect(utilityContainer?.querySelector('[title="Close"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows statistics section on dashboard", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
|
||||
@@ -22013,43 +22013,83 @@ html .column.drag-over * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
gap: var(--space-md);
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Identity area: icon + name + badges */
|
||||
.agent-detail-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-detail-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--todo);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-detail-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-detail-info h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-detail-badges {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Lifecycle controls: compact action buttons */
|
||||
.agent-detail-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Compact button variant for lifecycle controls */
|
||||
.btn--compact {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Utility actions: refresh + close */
|
||||
.agent-detail-utility-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Legacy class for backward compatibility */
|
||||
.agent-detail-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.agent-detail-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--todo);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.agent-detail-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-detail-info h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.agent-detail-badges {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Legacy class for backward compatibility */
|
||||
.agent-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -22059,7 +22099,7 @@ html .column.drag-over * {
|
||||
.agent-detail-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 24px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
@@ -22067,13 +22107,13 @@ html .column.drag-over * {
|
||||
.agent-detail-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
gap: var(--space-xs);
|
||||
padding: 10px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
@@ -22853,30 +22893,30 @@ html .column.drag-over * {
|
||||
|
||||
.agent-detail-header {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
padding: 16px;
|
||||
padding-top: max(16px, env(safe-area-inset-top, 0));
|
||||
gap: var(--space-sm);
|
||||
padding: 12px;
|
||||
padding-top: max(12px, env(safe-area-inset-top, 0));
|
||||
}
|
||||
|
||||
.agent-detail-title {
|
||||
.agent-detail-identity {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
gap: var(--space-md);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-detail-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-detail-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.agent-detail-info h2 {
|
||||
font-size: 17px;
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -22886,6 +22926,40 @@ html .column.drag-over * {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.agent-detail-controls {
|
||||
flex: 1 1 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.agent-detail-controls .btn--compact {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.agent-detail-controls .btn--compact svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.agent-detail-utility-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-detail-utility-actions .btn-icon {
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
/* Legacy selectors for backward compatibility */
|
||||
.agent-detail-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.agent-detail-actions {
|
||||
flex: 1 1 100%;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user