feat(FN-1490): merge fusion/fn-1490

This commit is contained in:
gsxdsm
2026-04-10 21:22:50 -07:00
parent ed700ba1d1
commit 7be8c17279
5 changed files with 229 additions and 70 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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>

View File

@@ -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

View File

@@ -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;