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:
@@ -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,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</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'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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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\)\)/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user