refactor(FN-2206): centralize agent modal styling in dashboard CSS

- Move inline styles from AgentListModal and AgentGenerationModal into token-based stylesheet classes
- Polish NewAgentDialog step-zero layout, role grid, and AI generation button/summary presentation
- Improve AgentImportModal result stat visual hierarchy and semantic status coloring
- Expand dashboard modal CSS coverage with sectioned rules and mobile-specific refinements
- Update component and stylesheet tests to assert new class usage and modal responsive behavior
This commit is contained in:
Fusion
2026-04-22 11:43:26 -07:00
committed by gsxdsm
parent adf53b721b
commit 317f4afa50
11 changed files with 938 additions and 1009 deletions

View File

@@ -168,22 +168,14 @@ export function AgentGenerationModal({
>
{/* Header */}
<div className="agent-dialog-header">
<span style={{ fontWeight: 600, fontSize: 15 }}>
<span style={{ marginRight: 8 }}></span>
<span className="agent-dialog-header-title">
<span className="agent-dialog-header-sparkle"></span>
Generate Agent
</span>
<button
className="modal-close"
onClick={handleCancel}
aria-label="Close"
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
fontSize: 18,
lineHeight: 1,
}}
>
&times;
</button>
@@ -191,31 +183,11 @@ export function AgentGenerationModal({
{/* Body */}
<div className="agent-dialog-body">
{error && (
<div
style={{
color: "var(--state-error-text, #f85149)",
fontSize: 13,
padding: "8px 12px",
background: "var(--state-error-bg, rgba(248,81,73,0.1))",
borderRadius: 6,
marginBottom: 12,
}}
>
{error}
</div>
)}
{error && <div className="agent-dialog-error-banner">{error}</div>}
{view.type === "input" && (
<div>
<p
style={{
color: "var(--text-muted)",
fontSize: 13,
marginTop: 0,
marginBottom: 12,
}}
>
<p className="agent-dialog-info">
Describe your agent&apos;s role and the AI will generate a complete
specification including system prompt, suggested configuration, and
more.
@@ -225,7 +197,7 @@ export function AgentGenerationModal({
<textarea
ref={textareaRef}
id="agent-role-description"
className="input"
className="input agent-dialog-textarea"
rows={4}
placeholder='e.g. "Senior frontend code reviewer who specializes in React accessibility"'
value={roleDescription}
@@ -237,22 +209,11 @@ export function AgentGenerationModal({
}
}}
maxLength={MAX_ROLE_LENGTH}
style={{
width: "100%",
boxSizing: "border-box",
resize: "vertical",
}}
aria-describedby="role-description-hint"
/>
<div
id="role-description-hint"
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 4,
display: "flex",
justifyContent: "space-between",
}}
className="agent-dialog-hint"
>
<span>Describe what your agent should do</span>
<span>
@@ -264,27 +225,9 @@ export function AgentGenerationModal({
)}
{view.type === "loading" && (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "32px 16px",
gap: 12,
}}
>
<div
className="spin"
style={{
width: 32,
height: 32,
border: "3px solid var(--border)",
borderTopColor: "var(--text-accent, #58a6ff)",
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
<p style={{ color: "var(--text-muted)", fontSize: 13, margin: 0 }}>
<div className="agent-dialog-loading-center">
<div className="agent-dialog-spinner spin" />
<p className="agent-dialog-loading-text">
Generating agent specification...
</p>
</div>
@@ -292,47 +235,37 @@ export function AgentGenerationModal({
{view.type === "preview" && (
<div>
<div className="agent-dialog-summary" style={{ marginBottom: 12 }}>
<div className="agent-dialog-summary agent-dialog-summary--spaced">
<div className="agent-dialog-summary-row">
<span
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
>
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
Title
</span>
<span style={{ fontWeight: 600 }}>
<span className="agent-dialog-summary-row-value">
{view.spec.icon} {view.spec.title}
</span>
</div>
<div className="agent-dialog-summary-row">
<span
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
>
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
Role
</span>
<span>{view.spec.role}</span>
</div>
<div className="agent-dialog-summary-row">
<span
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
>
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
Description
</span>
<span style={{ fontSize: 13 }}>{view.spec.description}</span>
<span className="agent-dialog-summary-row-value agent-dialog-summary-row-value--body">{view.spec.description}</span>
</div>
<div className="agent-dialog-summary-row">
<span
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
>
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
Thinking
</span>
<span style={{ textTransform: "capitalize" }}>
<span className="agent-dialog-summary-row-value agent-dialog-summary-row-value--capitalize">
{view.spec.thinkingLevel}
</span>
</div>
<div className="agent-dialog-summary-row">
<span
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
>
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
Max Turns
</span>
<span>{view.spec.maxTurns}</span>
@@ -345,51 +278,19 @@ export function AgentGenerationModal({
System Prompt
<button
type="button"
style={{
background: "none",
border: "none",
color: "var(--text-accent, #58a6ff)",
cursor: "pointer",
fontSize: 12,
marginLeft: 8,
padding: 0,
}}
className="agent-dialog-expand-btn"
onClick={() => setSystemPromptExpanded(!systemPromptExpanded)}
>
{systemPromptExpanded ? "Collapse" : "Expand"}
</button>
</label>
<div
style={{
background: "var(--bg-secondary, #161b22)",
border: "1px solid var(--border)",
borderRadius: 6,
padding: 12,
fontSize: 12,
fontFamily: "monospace",
maxHeight: systemPromptExpanded ? "none" : 150,
overflow: systemPromptExpanded ? "auto" : "hidden",
position: "relative",
lineHeight: 1.5,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
className={`agent-generation-prompt-box${systemPromptExpanded ? "" : " agent-generation-prompt-box--collapsed"}`}
>
{view.spec.systemPrompt}
{!systemPromptExpanded &&
view.spec.systemPrompt.length > 500 && (
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 40,
background:
"linear-gradient(transparent, var(--bg-secondary, #161b22))",
pointerEvents: "none",
}}
/>
<div className="agent-generation-prompt-fade" />
)}
</div>
</div>

View File

@@ -488,7 +488,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<span className="agent-import-browse-selected-name">{selectedCompany.name}</span>
<button
type="button"
className="btn btn--small"
className="btn btn-sm"
onClick={() => setSelectedCompany(null)}
>
Change
@@ -691,19 +691,16 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<div className="agent-import-result-stats">
{importResult.created.length > 0 && (
<div className="agent-import-result-stat agent-import-result-stat--success">
<CheckCircle size={14} />
<span>{importResult.created.length} created</span>
</div>
)}
{importResult.skipped.length > 0 && (
<div className="agent-import-result-stat agent-import-result-stat--skipped">
<span></span>
<span>{importResult.skipped.length} skipped (already exist)</span>
</div>
)}
{importResult.errors.length > 0 && (
<div className="agent-import-result-stat agent-import-result-stat--error">
<AlertTriangle size={14} />
<span>{importResult.errors.length} error{importResult.errors.length !== 1 ? "s" : ""}</span>
</div>
)}
@@ -738,25 +735,21 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<div className="agent-import-result-stats">
{importResult.skills.imported.length > 0 && (
<div className="agent-import-result-stat agent-import-result-stat--success">
<CheckCircle size={14} />
<span>{importResult.skills.imported.length} skill{importResult.skills.imported.length !== 1 ? "s" : ""} imported</span>
</div>
)}
{importResult.skills.skipped.length > 0 && (
<div className="agent-import-result-stat agent-import-result-stat--skipped">
<span></span>
<span>{importResult.skills.skipped.length} skill{importResult.skills.skipped.length !== 1 ? "s" : ""} skipped (already exist)</span>
</div>
)}
{importResult.skills.errors.length > 0 && (
<div className="agent-import-result-stat agent-import-result-stat--error">
<AlertTriangle size={14} />
<span>{importResult.skills.errors.length} skill{importResult.skills.errors.length !== 1 ? "s" : ""} error{importResult.skills.errors.length !== 1 ? "s" : ""}</span>
</div>
)}
{importResult.skills.imported.length === 0 && importResult.skills.skipped.length === 0 && importResult.skills.errors.length === 0 && (
<div className="agent-import-result-stat agent-import-result-stat--skipped">
<span></span>
<span>No skills in package</span>
</div>
)}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { X, Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, Filter } from "lucide-react";
import { Plus, Play, Pause, Square, Trash2, RefreshCw, Bot, LayoutGrid, List, Filter } from "lucide-react";
import type { Agent, AgentCapability, AgentState } from "../api";
import { fetchAgents, createAgent, updateAgent, updateAgentState, deleteAgent } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
@@ -23,15 +23,6 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
{ value: "custom", label: "Custom", icon: "✦" },
];
const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: string }> = {
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)" },
running: { 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)" },
error: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
};
export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentListModalProps) {
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -173,11 +164,18 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
return getAgentHealthStatus(agent);
};
const getHealthTone = (health: AgentHealthStatus): "active" | "paused" | "error" | "muted" => {
if (health.color === "var(--state-active-text)") return "active";
if (health.color === "var(--state-paused-text)") return "paused";
if (health.color === "var(--state-error-text)") return "error";
return "muted";
};
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true">
<div className="modal modal--wide">
<div className="modal modal--wide agent-list-modal">
<div className="modal-header">
<h2 className="modal-title">
<Bot size={20} />
@@ -289,22 +287,18 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
// Board view: compact grid layout
displayAgents.map(agent => {
const health = getHealthStatus(agent);
const stateStyle = STATE_COLORS[agent.state];
const healthTone = getHealthTone(health);
return (
<div key={agent.id} className="agent-board-card" style={{ borderColor: stateStyle.border }}>
<div key={agent.id} className="agent-board-card" data-state={agent.state}>
<div className="agent-board-header">
<span className="agent-board-icon">{getRoleIcon(agent.role)}</span>
<span
className="agent-board-badge"
style={{
background: stateStyle.bg,
color: stateStyle.text,
border: `1px solid ${stateStyle.border}`,
}}
data-state={agent.state}
>
{agent.state}
</span>
<span className="agent-board-health" style={{ color: health.color }} title={health.label}>
<span className="agent-board-health" data-health={healthTone} title={health.label}>
{health.icon}
</span>
</div>
@@ -420,9 +414,9 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
// List view: detailed card layout
displayAgents.map(agent => {
const health = getHealthStatus(agent);
const stateStyle = STATE_COLORS[agent.state];
const healthTone = getHealthTone(health);
return (
<div key={agent.id} className="agent-card" style={{ borderLeftColor: stateStyle.border }}>
<div key={agent.id} className="agent-card" data-state={agent.state}>
<div className="agent-card-header">
<div className="agent-info">
{editingRoleForAgent === agent.id ? (
@@ -464,16 +458,12 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
</div>
<div className="agent-badges">
<span
className="badge"
style={{
background: stateStyle.bg,
color: stateStyle.text,
border: `1px solid ${stateStyle.border}`,
}}
className="badge agent-list-state-badge"
data-state={agent.state}
>
{agent.state}
</span>
<span className="badge" style={{ color: health.color }} title={health.label}>
<span className="badge agent-list-health-badge" data-health={healthTone} title={health.label}>
{health.icon}{!health.stateDerived && ` ${health.label}`}
</span>
<span className="badge text-secondary">
@@ -614,479 +604,6 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
</div>
</div>
</div>
<style>{`
:host, .modal--wide {
/* Scoped alias — maps to the global --text-muted defined in styles.css */
--text-secondary: var(--text-muted);
}
/* === Modal shell === */
.modal--wide {
width: 90vw;
max-width: 900px;
max-height: 80vh;
}
.modal-title {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.3px;
margin: 0;
}
/* === Content area === */
.agent-modal-content {
padding: var(--space-xl, 24px) 20px;
overflow-y: auto;
}
/* === Controls bar === */
.agent-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md, 12px);
margin-bottom: var(--space-lg, 16px);
}
.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;
}
/* === Create form === */
.agent-create-form {
display: flex;
gap: var(--space-md, 12px);
align-items: center;
margin-bottom: var(--space-lg, 16px);
padding: var(--space-lg, 16px);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md, 8px);
}
.agent-create-form .input {
flex: 1;
min-width: 0;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font-size: 13px;
font-family: var(--font-primary);
outline: none;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.agent-create-form .input:focus {
border-color: var(--todo);
box-shadow: var(--focus-ring);
}
.agent-create-form .input::placeholder {
color: var(--text-dim);
}
.agent-create-form .select {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font-size: 13px;
font-family: var(--font-primary);
cursor: pointer;
outline: none;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.agent-create-form .select:focus {
border-color: var(--todo);
box-shadow: var(--focus-ring);
}
/* === Agent list (default view) === */
.agent-list {
display: flex;
flex-direction: column;
gap: var(--space-md, 12px);
}
/* === Agent board (grid view) === */
.agent-board {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-lg, 16px);
}
/* === Board cards === */
.agent-board-card {
display: flex;
flex-direction: column;
gap: var(--space-sm, 8px);
padding: var(--space-lg, 16px);
background: var(--surface, var(--bg-primary));
border: 1px solid var(--border);
border-top-width: 3px;
border-radius: var(--radius-md, 8px);
transition: background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.agent-board-card:hover {
background: var(--card-hover);
border-color: var(--text-muted);
box-shadow: var(--shadow-sm);
}
.agent-board-card:focus-within {
box-shadow: var(--focus-ring);
}
.agent-board-header {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
}
.agent-board-icon {
font-size: 20px;
line-height: 1;
}
.agent-board-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 6px;
border-radius: var(--radius-sm);
margin-left: auto;
}
.agent-board-health {
display: flex;
align-items: center;
}
.agent-board-name {
font-weight: 600;
font-size: 14px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-board-id {
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-secondary);
line-height: 1.3;
}
.agent-board-actions {
display: flex;
gap: 6px;
margin-top: var(--space-xs, 4px);
padding-top: var(--space-sm, 8px);
border-top: 1px solid var(--border);
}
.agent-board-actions .btn {
flex: 1;
justify-content: center;
}
/* === Empty state === */
.agent-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm, 8px);
padding: 48px 20px;
color: var(--text-secondary);
text-align: center;
}
.agent-empty p {
margin: 0;
}
/* === List cards === */
.agent-card {
border: 1px solid var(--border);
border-left-width: 4px;
border-radius: var(--radius-md, 8px);
padding: var(--space-lg, 16px);
background: var(--surface, var(--bg-primary));
transition: background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.agent-card:hover {
background: var(--card-hover);
}
.agent-card:focus-within {
box-shadow: var(--focus-ring);
}
.agent-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-md, 12px);
margin-bottom: var(--space-md, 12px);
}
.agent-info {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
min-width: 0;
}
.agent-icon {
font-size: 24px;
flex-shrink: 0;
}
.agent-icon--clickable {
cursor: pointer;
transition: opacity 0.2s ease, transform 0.2s ease;
user-select: none;
}
.agent-icon--clickable:hover {
opacity: 0.7;
transform: scale(1.1);
}
.agent-icon--clickable:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.agent-role-select {
font-size: 14px;
padding: 4px 8px;
min-width: 120px;
width: auto;
cursor: pointer;
}
.agent-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.agent-name {
font-weight: 600;
font-size: 15px;
line-height: 1.3;
}
.agent-id {
font-size: 12px;
font-family: var(--font-mono);
line-height: 1.3;
}
.agent-badges {
display: flex;
gap: var(--space-sm, 8px);
flex-wrap: wrap;
align-items: center;
flex-shrink: 0;
}
.agent-badges .badge {
white-space: nowrap;
}
.agent-card-body {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: var(--space-md, 12px);
padding: var(--space-sm, 8px);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.4;
}
.agent-task,
.agent-heartbeat {
display: flex;
gap: var(--space-sm, 8px);
align-items: baseline;
}
.agent-card-actions {
display: flex;
gap: var(--space-sm, 8px);
flex-wrap: wrap;
}
.agent-card-actions .btn {
transition: transform var(--transition-fast);
}
.agent-card-actions .btn:active {
transform: scale(0.97);
}
/* === Utility === */
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.text-secondary {
color: var(--text-secondary);
}
/* === Responsive: tablet (<=768px) === */
@media (max-width: 768px) {
.modal--wide {
width: 100%;
max-width: 100%;
max-height: 100vh;
max-height: 100dvh;
border-radius: 0;
}
.agent-modal-content {
padding: var(--space-lg, 16px) var(--space-lg, 16px);
padding-bottom: max(var(--space-lg, 16px), env(safe-area-inset-bottom, 0px));
}
.agent-state-filter-select {
font-size: 16px;
min-height: 44px;
}
.agent-board-actions .btn {
min-height: 44px;
min-width: 44px;
}
.view-toggle-btn {
min-height: 44px;
min-width: 44px;
}
/* Header actions wrap safely */
.modal-header .modal-actions {
flex-wrap: wrap;
gap: var(--space-xs, 4px);
}
/* Board goes to 2-column max */
.agent-board {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-md, 12px);
}
/* List cards stack badges under info */
.agent-card-header {
flex-direction: column;
gap: var(--space-sm, 8px);
}
.agent-badges {
flex-shrink: initial;
}
/* Reduce card body padding on mobile */
.agent-empty {
padding: var(--space-xl, 24px) var(--space-lg, 16px);
}
}
/* === Responsive: narrow (<=640px) === */
@media (max-width: 640px) {
/* Controls stack vertically */
.agent-controls {
flex-direction: column;
align-items: stretch;
}
/* Create form stacks fields */
.agent-create-form {
flex-direction: column;
align-items: stretch;
}
.agent-create-form .input,
.agent-create-form .select {
width: 100%;
font-size: 16px;
min-height: 44px;
}
.agent-create-form .btn {
min-height: 44px;
}
/* Board goes to single-column */
.agent-board {
grid-template-columns: 1fr;
}
/* Card actions wrap without overflow */
.agent-card-actions {
flex-wrap: wrap;
}
.agent-card-actions .btn {
flex: 1;
min-width: 0;
}
}
`}</style>
</div>
</div>
);
}

View File

@@ -266,98 +266,115 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
))}
</div>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-name">Name {!selectedPresetId && <span className="agent-dialog-required">*</span>}</label>
<input
id="agent-name"
type="text"
className="input"
placeholder="e.g. Frontend Reviewer"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-title">Title <span className="agent-dialog-optional">(optional)</span></label>
<input
id="agent-title"
type="text"
className="input"
placeholder="e.g. Senior Code Reviewer"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label>Role</label>
<div className="agent-role-grid">
{AGENT_ROLES.map(r => (
<button
key={r.value}
type="button"
className={`agent-role-option${role === r.value ? " selected" : ""}`}
onClick={() => setRole(r.value)}
>
<span className="agent-role-option-icon">{r.icon}</span>
<span className="agent-role-option-label">{r.label}</span>
</button>
))}
<div className="agent-dialog-section">
<div className="agent-dialog-section-header">Identity</div>
<div className="agent-dialog-field">
<label htmlFor="agent-name">Name {!selectedPresetId && <span className="agent-dialog-required">*</span>}</label>
<input
id="agent-name"
type="text"
className="input"
placeholder="e.g. Frontend Reviewer"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-title">Title <span className="agent-dialog-optional">(optional)</span></label>
<input
id="agent-title"
type="text"
className="input"
placeholder="e.g. Senior Code Reviewer"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-icon">Icon <span className="agent-dialog-optional">(optional)</span></label>
<input
id="agent-icon"
type="text"
className="input"
placeholder="e.g. 🤖"
value={icon}
onChange={e => setIcon(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label>Role</label>
<div className="agent-role-grid">
{AGENT_ROLES.map(r => (
<button
key={r.value}
type="button"
className={`agent-role-option${role === r.value ? " selected" : ""}`}
onClick={() => setRole(r.value)}
>
<span className="agent-role-option-icon">{r.icon}</span>
<span className="agent-role-option-label">{r.label}</span>
</button>
))}
</div>
</div>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-reports-to">Reports To <span className="agent-dialog-optional">(optional agent ID)</span></label>
<input
id="agent-reports-to"
type="text"
className="input"
placeholder="e.g. agent-1234abcd"
value={reportsTo}
onChange={e => setReportsTo(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-soul">Soul <span className="agent-dialog-optional">(optional)</span></label>
<textarea
id="agent-soul"
className="input"
rows={2}
placeholder="Describe the agent's personality and communication style..."
value={soul}
onChange={e => setSoul(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-memory">Agent Memory <span className="agent-dialog-optional">(optional)</span></label>
<textarea
id="agent-memory"
className="input"
rows={2}
placeholder="Private to this agent — durable preferences, operating habits, and context it should carry across tasks..."
value={memory}
onChange={e => setMemory(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-instructions-path">Instructions Path <span className="agent-dialog-optional">(optional)</span></label>
<input
id="agent-instructions-path"
type="text"
className="input"
placeholder="e.g. .fusion/agents/reviewer.md"
value={instructionsPath}
onChange={e => setInstructionsPath(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-instructions-text">Inline Instructions <span className="agent-dialog-optional">(optional)</span></label>
<textarea
id="agent-instructions-text"
className="input"
rows={4}
placeholder="Add custom behavior instructions..."
value={instructionsText}
onChange={e => setInstructionsText(e.target.value)}
/>
<div className="agent-dialog-section">
<div className="agent-dialog-section-header">Configuration</div>
<div className="agent-dialog-field">
<label htmlFor="agent-reports-to">Reports To <span className="agent-dialog-optional">(optional agent ID)</span></label>
<input
id="agent-reports-to"
type="text"
className="input"
placeholder="e.g. agent-1234abcd"
value={reportsTo}
onChange={e => setReportsTo(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-soul">Soul <span className="agent-dialog-optional">(optional)</span></label>
<textarea
id="agent-soul"
className="input"
rows={2}
placeholder="Describe the agent's personality and communication style..."
value={soul}
onChange={e => setSoul(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-memory">Agent Memory <span className="agent-dialog-optional">(optional)</span></label>
<textarea
id="agent-memory"
className="input"
rows={2}
placeholder="Private to this agent — durable preferences, operating habits, and context it should carry across tasks..."
value={memory}
onChange={e => setMemory(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-instructions-path">Instructions Path <span className="agent-dialog-optional">(optional)</span></label>
<input
id="agent-instructions-path"
type="text"
className="input"
placeholder="e.g. .fusion/agents/reviewer.md"
value={instructionsPath}
onChange={e => setInstructionsPath(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-instructions-text">Inline Instructions <span className="agent-dialog-optional">(optional)</span></label>
<textarea
id="agent-instructions-text"
className="input"
rows={4}
placeholder="Add custom behavior instructions..."
value={instructionsText}
onChange={e => setInstructionsText(e.target.value)}
/>
</div>
</div>
{/* AI-assisted generation */}
<div className="agent-dialog-ai-generate">

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import fs from "node:fs";
import path from "node:path";
import { AgentGenerationModal } from "../AgentGenerationModal";
import * as api from "../../api";
import type { AgentGenerationSpec } from "../../api";
@@ -15,6 +17,9 @@ const mockStartAgentGeneration = vi.mocked(api.startAgentGeneration);
const mockGenerateAgentSpec = vi.mocked(api.generateAgentSpec);
const mockCancelAgentGeneration = vi.mocked(api.cancelAgentGeneration);
const stylesPath = path.join(__dirname, "../../styles.css");
const readStyles = () => fs.readFileSync(stylesPath, "utf-8");
const generatedSpec: AgentGenerationSpec = {
title: "Accessibility Reviewer",
icon: "♿",
@@ -77,6 +82,17 @@ describe("AgentGenerationModal", () => {
expect(screen.getByLabelText("Role Description")).toBeInTheDocument();
});
it("uses token-based agent dialog styles in styles.css", () => {
const styles = readStyles();
expect(styles).toContain('.agent-dialog-overlay');
expect(styles).toContain('background: color-mix(in srgb, var(--bg) 60%, transparent);');
expect(styles).toContain('.btn--ai-generate');
expect(styles).toContain('background: var(--cta-bg, var(--todo));');
expect(styles).toContain('.agent-dialog-field label');
expect(styles).toContain('margin-bottom: calc(var(--space-sm) - var(--space-xs) * 0.5);');
});
it("focuses role description textarea on open", () => {
renderModal(true);

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import fs from "node:fs";
import path from "node:path";
import { AgentImportModal } from "../AgentImportModal";
interface MockResponse {
@@ -17,6 +19,9 @@ function mockResponse({ ok, status = ok ? 200 : 400, body }: MockResponse): Prom
} as Response);
}
const stylesPath = path.join(__dirname, "../../styles.css");
const readStyles = () => fs.readFileSync(stylesPath, "utf-8");
describe("AgentImportModal", () => {
const onClose = vi.fn();
const onImported = vi.fn();
@@ -444,6 +449,17 @@ describe("AgentImportModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("uses token-based semantic colors for result and browse styling", () => {
const styles = readStyles();
expect(styles).toContain('.agent-import-result-stat--success');
expect(styles).toContain('color: var(--color-success);');
expect(styles).toContain('.agent-import-result-error');
expect(styles).toContain('background: color-mix(in srgb, var(--color-error) 8%, transparent);');
expect(styles).toContain('.agent-import-browse-selected');
expect(styles).toContain('background: color-mix(in srgb, var(--todo) 10%, transparent);');
});
describe("Browse Catalog Mode", () => {
function createMockResponse(body: unknown): Response {
return {
@@ -490,6 +506,24 @@ describe("AgentImportModal", () => {
});
});
it("uses the shared small button class for selected catalog actions", async () => {
const mockCompanies = [
{ slug: "test-company", name: "Test Company", tagline: "A great company" },
];
globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse({ companies: mockCompanies }));
renderModal(true);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Browse Catalog" }));
await user.click(await screen.findByText("Test Company"));
const changeButton = await screen.findByRole("button", { name: "Change" });
expect(changeButton).toHaveClass("btn-sm");
expect(changeButton).not.toHaveClass("btn--small");
});
it("shows Retry button when error occurs", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse({
companies: [],

View File

@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import fs from "node:fs";
import path from "node:path";
import { AgentListModal } from "../AgentListModal";
import * as apiModule from "../../api";
import type { Agent, AgentState, AgentCapability } from "../../api";
@@ -18,6 +20,9 @@ const mockCreateAgent = vi.mocked(apiModule.createAgent);
const mockUpdateAgentState = vi.mocked(apiModule.updateAgentState);
const mockDeleteAgent = vi.mocked(apiModule.deleteAgent);
const stylesPath = path.join(__dirname, "../../styles.css");
const readStyles = () => fs.readFileSync(stylesPath, "utf-8");
describe("AgentListModal", () => {
const mockOnClose = vi.fn();
const mockAddToast = vi.fn();
@@ -271,6 +276,23 @@ describe("AgentListModal", () => {
expect(screen.getByText("Healthy")).toBeTruthy();
});
});
it("renders health badges via data attributes instead of inline color styles", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
const healthyBadge = await screen.findByText("Healthy");
const badge = healthyBadge.closest(".agent-list-health-badge") as HTMLElement | null;
expect(badge).toBeTruthy();
expect(badge).toHaveAttribute("data-health", "active");
expect(badge).not.toHaveAttribute("style");
});
});
describe("agent creation", () => {
@@ -936,12 +958,12 @@ describe("AgentListModal", () => {
expect(screen.getByText("Agents")).toBeTruthy();
});
// Agent state variables are defined globally in styles.css (:root),
// not duplicated in the inline style tag. The inline style only
// defines the scoped --text-secondary alias.
const styleTag = document.querySelector("style");
expect(styleTag).toBeTruthy();
expect(styleTag?.textContent).toContain("--text-secondary");
const styles = readStyles();
expect(styles).toContain('.agent-list-modal .agent-board-badge[data-state="idle"]');
expect(styles).toContain('var(--state-idle-bg)');
expect(styles).toContain('.agent-list-modal .agent-list-state-badge[data-state="error"]');
expect(styles).toContain('.agent-list-modal .agent-list-health-badge[data-health="active"]');
expect(styles).toContain('.agent-list-modal .agent-board-health[data-health="error"]');
});
});
@@ -965,18 +987,10 @@ describe("AgentListModal", () => {
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);
const styles = readStyles();
expect(styles).toContain('.agent-list-modal .agent-create-form');
expect(styles).toContain('border-radius: var(--radius-md)');
expect(styles).not.toMatch(/\.agent-list-modal \.agent-create-form\s*\{[^}]*border-radius:\s*8px/);
});
it("create form input and select use theme tokens", async () => {
@@ -994,35 +1008,18 @@ describe("AgentListModal", () => {
fireEvent.click(screen.getByText("New Agent"));
const styleElements = document.querySelectorAll("style");
let foundInputRule = false;
let foundSelectRule = false;
styleElements.forEach(styleEl => {
const css = styleEl.textContent ?? "";
if (css.includes(".agent-create-form .input")) {
foundInputRule = true;
// Assert theme token usage
expect(css).toContain("var(--surface)");
expect(css).toContain("var(--text)");
expect(css).toContain("var(--border)");
expect(css).toContain("var(--radius-sm)");
// Focus ring token
expect(css).toContain("var(--focus-ring)");
// Guard against hardcoded light-only styles
expect(css).not.toMatch(/background:\s*#fff/);
expect(css).not.toMatch(/background:\s*white/);
}
if (css.includes(".agent-create-form .select")) {
foundSelectRule = true;
expect(css).toContain("var(--surface)");
expect(css).toContain("var(--text)");
expect(css).toContain("var(--border)");
expect(css).toContain("var(--radius-sm)");
expect(css).toContain("var(--focus-ring)");
}
});
expect(foundInputRule).toBe(true);
expect(foundSelectRule).toBe(true);
const styles = readStyles();
expect(styles).toContain('.agent-list-modal .agent-create-form .input');
expect(styles).toContain('.agent-list-modal .agent-create-form .input');
expect(styles).toContain('flex: 1;');
expect(styles).toContain('min-width: 0;');
expect(styles).toContain('var(--surface)');
expect(styles).toContain('var(--text)');
expect(styles).toContain('var(--border)');
expect(styles).toContain('var(--radius-sm)');
expect(styles).toContain('var(--focus-ring)');
expect(styles).not.toMatch(/background:\s*#fff/);
expect(styles).not.toMatch(/background:\s*white/);
});
it("renders filter with styled container matching AgentsView", async () => {
@@ -1061,20 +1058,11 @@ describe("AgentListModal", () => {
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);
const styles = readStyles();
expect(styles).toContain('.agent-state-filter {');
expect(styles).toContain('border-radius: var(--radius-sm)');
expect(styles).toContain('.agent-state-filter:hover');
expect(styles).toContain('.agent-state-filter:focus-within');
});
});
@@ -1397,22 +1385,10 @@ describe("AgentListModal", () => {
expect(screen.getByText("Agents")).toBeTruthy();
});
const styleElements = document.querySelectorAll("style");
let foundCardHover = false;
let foundBoardCardHover = false;
styleElements.forEach(styleEl => {
const css = styleEl.textContent ?? "";
if (css.includes(".agent-card:hover")) {
foundCardHover = true;
// Should transition background
expect(css).toContain("transition:");
}
if (css.includes(".agent-board-card:hover")) {
foundBoardCardHover = true;
}
});
expect(foundCardHover).toBe(true);
expect(foundBoardCardHover).toBe(true);
const styles = readStyles();
expect(styles).toContain('.agent-list-modal .agent-card:hover');
expect(styles).toContain('.agent-list-modal .agent-board-card:hover');
expect(styles).toContain('transition: background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);');
});
it("CSS includes responsive media queries for mobile", async () => {
@@ -1428,22 +1404,14 @@ describe("AgentListModal", () => {
expect(screen.getByText("Agents")).toBeTruthy();
});
const styleElements = document.querySelectorAll("style");
const allCss = Array.from(styleElements).map(el => el.textContent ?? "").join("");
const styles = readStyles();
// Should have responsive breakpoints
expect(allCss).toContain("@media (max-width: 768px)");
expect(allCss).toContain("@media (max-width: 640px)");
// 768px: board grid narrows
expect(allCss).toContain("grid-template-columns: repeat(auto-fill, minmax(160px, 1fr))");
// 640px: controls and create form stack
expect(allCss).toContain(".agent-controls");
expect(allCss).toContain(".agent-create-form");
// 640px: board goes single-column
expect(allCss).toContain("grid-template-columns: 1fr");
expect(styles).toContain("@media (max-width: 768px)");
expect(styles).toContain("@media (max-width: 640px)");
expect(styles).toContain("grid-template-columns: repeat(auto-fill, minmax(calc(var(--space-xl) * 6 + var(--space-lg)), 1fr))");
expect(styles).toContain(".agent-list-modal .agent-controls");
expect(styles).toContain(".agent-list-modal .agent-create-form");
expect(styles).toContain("grid-template-columns: 1fr");
});
it("no regressions in open/close behavior after styling changes", async () => {

View File

@@ -139,6 +139,19 @@ describe("NewAgentDialog", () => {
});
expect(screen.getByRole("dialog", { name: "Create new agent" })).toBeTruthy();
});
it("shows Identity and Configuration section headers on step 0", async () => {
await act(async () => {
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
});
expect(screen.getByText("Identity")).toBeInTheDocument();
expect(screen.getByText("Configuration")).toBeInTheDocument();
expect(screen.getByLabelText(/Name/)).toBeInTheDocument();
expect(screen.getByLabelText(/Reports To/)).toBeInTheDocument();
});
});
describe("model dropdown", () => {

View File

@@ -63,12 +63,6 @@ function readStyles(): string {
return fs.readFileSync(stylesPath, "utf-8");
}
function readRenderedInlineStyles(): string {
return Array.from(document.querySelectorAll("style"))
.map((el) => el.textContent ?? "")
.join("\n");
}
describe("agent modal mobile CSS structure", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -262,38 +256,38 @@ describe("agent modal mobile CSS structure", () => {
});
});
it("inline CSS contains 768px and 640px breakpoints", async () => {
it("styles.css contains 768px and 640px breakpoints for agent list modal", async () => {
render(<AgentListModal isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeInTheDocument();
});
const css = readRenderedInlineStyles();
expect(css).toContain("@media (max-width: 768px)");
expect(css).toContain("@media (max-width: 640px)");
const styles = readStyles();
expect(styles).toContain("@media (max-width: 768px)");
expect(styles).toContain("@media (max-width: 640px)");
});
it("inline CSS has board single-column at 640px", async () => {
it("styles.css has board single-column at 640px", async () => {
render(<AgentListModal isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeInTheDocument();
});
const css = readRenderedInlineStyles();
expect(css).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.agent-board\s*{[\s\S]*?grid-template-columns:\s*1fr/);
const styles = readStyles();
expect(styles).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.agent-list-modal \.agent-board\s*{[\s\S]*?grid-template-columns:\s*1fr/);
});
it("inline CSS has controls stacking at 640px", async () => {
it("styles.css has controls stacking at 640px", async () => {
render(<AgentListModal isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeInTheDocument();
});
const css = readRenderedInlineStyles();
expect(css).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.agent-controls\s*{[\s\S]*?flex-direction:\s*column/);
const styles = readStyles();
expect(styles).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.agent-list-modal \.agent-controls\s*{[\s\S]*?flex-direction:\s*column/);
});
});
@@ -303,9 +297,9 @@ describe("agent modal mobile CSS structure", () => {
expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.agent-dialog[\s\S]*?safe-area-inset-bottom/);
});
it("agent-dialog mobile rules include 16px field font-size", () => {
it("agent-dialog mobile rules tokenize field font-size for iOS zoom prevention", () => {
const styles = readStyles();
expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.agent-dialog-field[\s\S]*?font-size:\s*16px/);
expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.agent-dialog-field[\s\S]*?font-size:\s*calc\(var\(--space-md\) \+ var\(--space-xs\)\)/);
});
});
});