feat(FN-909): redesign Agent Manager UI with CSS class approach and design tokens

- Replace AgentDetailView and AgentsView components with CSS class-based styling approach
- Integrate AI generation feature from FN-865 using CSS class methodology
- Fix CSS class verification test regex and add missing .agent-detail-info rule
- Refactor NewAgentDialog to align with new component structure
- Redesign Agent Manager UI using global design tokens for consistent theming
- Expand styles.css with comprehensive agent-specific CSS rules and design system variables
- Add agent-css-classes.test.ts to verify CSS class coverage and correctness
This commit is contained in:
gsxdsm
2026-04-05 07:26:30 -07:00
parent 7f3a100202
commit fd62e0c0f3
6 changed files with 1551 additions and 1321 deletions

View File

@@ -0,0 +1,227 @@
import { describe, it, expect } from "vitest";
import * as fs from "fs";
import * as path from "path";
const stylesPath = path.join(__dirname, "../styles.css");
const stylesContent = fs.readFileSync(stylesPath, "utf-8");
// Agent component file paths to verify inline <style> blocks are removed
const agentsViewContent = fs.readFileSync(path.join(__dirname, "../components/AgentsView.tsx"), "utf-8");
const agentDetailViewContent = fs.readFileSync(path.join(__dirname, "../components/AgentDetailView.tsx"), "utf-8");
const activeAgentsPanelContent = fs.readFileSync(path.join(__dirname, "../components/ActiveAgentsPanel.tsx"), "utf-8");
const newAgentDialogContent = fs.readFileSync(path.join(__dirname, "../components/NewAgentDialog.tsx"), "utf-8");
/** Check that styles.css contains a CSS class definition for the given selector */
function hasClass(cls: string): boolean {
const escaped = cls.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Match standalone class or class within a grouped selector list
return new RegExp(`${escaped}\\s*(,|\\{)`).test(stylesContent);
}
describe("Agent CSS classes", () => {
// Verify agent state CSS variables are defined in the global stylesheet
it("should define --state-* CSS variables", () => {
expect(stylesContent).toContain("--state-idle-bg:");
expect(stylesContent).toContain("--state-idle-text:");
expect(stylesContent).toContain("--state-idle-border:");
expect(stylesContent).toContain("--state-active-bg:");
expect(stylesContent).toContain("--state-active-text:");
expect(stylesContent).toContain("--state-active-border:");
expect(stylesContent).toContain("--state-paused-bg:");
expect(stylesContent).toContain("--state-paused-text:");
expect(stylesContent).toContain("--state-paused-border:");
expect(stylesContent).toContain("--state-error-bg:");
expect(stylesContent).toContain("--state-error-text:");
expect(stylesContent).toContain("--state-error-border:");
});
// Verify BEM button modifier classes exist
it("should define BEM button modifier classes", () => {
expect(hasClass(".btn--sm")).toBe(true);
expect(hasClass(".btn--primary")).toBe(true);
expect(hasClass(".btn--danger")).toBe(true);
expect(hasClass(".btn--warning")).toBe(true);
});
// Verify badge base class
it("should define .badge base class", () => {
expect(hasClass(".badge")).toBe(true);
});
// Verify AgentMetricsBar classes
it("should define AgentMetricsBar CSS classes", () => {
expect(hasClass(".agent-metrics-bar")).toBe(true);
expect(hasClass(".agent-metric-card")).toBe(true);
expect(hasClass(".agent-metric-info")).toBe(true);
expect(hasClass(".agent-metric-value")).toBe(true);
expect(hasClass(".agent-metric-label")).toBe(true);
});
// Verify AgentsView classes
it("should define AgentsView CSS classes", () => {
expect(hasClass(".agents-view")).toBe(true);
expect(hasClass(".agents-view-header")).toBe(true);
expect(hasClass(".agents-view-title")).toBe(true);
expect(hasClass(".agents-view-controls")).toBe(true);
expect(hasClass(".agents-view-content")).toBe(true);
expect(hasClass(".agent-controls")).toBe(true);
expect(hasClass(".agent-state-filter")).toBe(true);
expect(hasClass(".agent-state-filter-select")).toBe(true);
expect(hasClass(".agent-board")).toBe(true);
expect(hasClass(".agent-board-card")).toBe(true);
expect(hasClass(".agent-board-header")).toBe(true);
expect(hasClass(".agent-board-icon")).toBe(true);
expect(hasClass(".agent-board-badge")).toBe(true);
expect(hasClass(".agent-board-health")).toBe(true);
expect(hasClass(".agent-board-name")).toBe(true);
expect(hasClass(".agent-board-id")).toBe(true);
expect(hasClass(".agent-board-clickable")).toBe(true);
expect(hasClass(".agent-board-actions")).toBe(true);
expect(hasClass(".agent-list")).toBe(true);
expect(hasClass(".agent-card")).toBe(true);
expect(hasClass(".agent-card-header")).toBe(true);
expect(hasClass(".agent-card-body")).toBe(true);
expect(hasClass(".agent-card-actions")).toBe(true);
expect(hasClass(".agent-info")).toBe(true);
expect(hasClass(".agent-info--clickable")).toBe(true);
expect(hasClass(".agent-icon")).toBe(true);
expect(hasClass(".agent-icon--clickable")).toBe(true);
expect(hasClass(".agent-meta")).toBe(true);
expect(hasClass(".agent-name")).toBe(true);
expect(hasClass(".agent-id")).toBe(true);
expect(hasClass(".agent-badges")).toBe(true);
expect(hasClass(".agent-card-chevron")).toBe(true);
expect(hasClass(".agent-task")).toBe(true);
expect(hasClass(".agent-heartbeat")).toBe(true);
expect(hasClass(".agent-role-select")).toBe(true);
expect(hasClass(".agent-empty")).toBe(true);
expect(hasClass(".spin")).toBe(true);
});
// Verify AgentDetailView classes
it("should define AgentDetailView CSS classes", () => {
expect(hasClass(".agent-detail-overlay")).toBe(true);
expect(hasClass(".agent-detail-modal")).toBe(true);
expect(hasClass(".agent-detail-loading")).toBe(true);
expect(hasClass(".agent-detail-header")).toBe(true);
expect(hasClass(".agent-detail-title")).toBe(true);
expect(hasClass(".agent-detail-icon")).toBe(true);
expect(hasClass(".agent-detail-info")).toBe(true);
expect(hasClass(".agent-detail-badges")).toBe(true);
expect(hasClass(".agent-detail-actions")).toBe(true);
expect(hasClass(".agent-detail-tabs")).toBe(true);
expect(hasClass(".agent-detail-tab")).toBe(true);
expect(hasClass(".agent-detail-content")).toBe(true);
expect(hasClass(".agent-detail-footer")).toBe(true);
expect(hasClass(".agent-detail-id")).toBe(true);
expect(hasClass(".dashboard-tab")).toBe(true);
expect(hasClass(".dashboard-section")).toBe(true);
expect(hasClass(".info-grid")).toBe(true);
expect(hasClass(".info-item")).toBe(true);
expect(hasClass(".info-label")).toBe(true);
expect(hasClass(".info-value")).toBe(true);
expect(hasClass(".inline-badge")).toBe(true);
expect(hasClass(".stats-grid")).toBe(true);
expect(hasClass(".stat-card")).toBe(true);
expect(hasClass(".stat-value")).toBe(true);
expect(hasClass(".stat-label")).toBe(true);
expect(hasClass(".current-task")).toBe(true);
expect(hasClass(".task-badge")).toBe(true);
expect(hasClass(".metadata-json")).toBe(true);
expect(hasClass(".logs-tab")).toBe(true);
expect(hasClass(".logs-header")).toBe(true);
expect(hasClass(".logs-count")).toBe(true);
expect(hasClass(".streaming-indicator")).toBe(true);
expect(hasClass(".streaming-dot")).toBe(true);
expect(hasClass(".logs-container")).toBe(true);
expect(hasClass(".logs-empty")).toBe(true);
expect(hasClass(".log-entry")).toBe(true);
expect(hasClass(".log-timestamp")).toBe(true);
expect(hasClass(".log-agent")).toBe(true);
expect(hasClass(".log-icon")).toBe(true);
expect(hasClass(".log-text")).toBe(true);
expect(hasClass(".log-detail")).toBe(true);
expect(hasClass(".runs-tab")).toBe(true);
expect(hasClass(".runs-empty")).toBe(true);
expect(hasClass(".run-card")).toBe(true);
expect(hasClass(".run-card--active")).toBe(true);
expect(hasClass(".run-header")).toBe(true);
expect(hasClass(".run-live-indicator")).toBe(true);
expect(hasClass(".live-dot")).toBe(true);
expect(hasClass(".run-id")).toBe(true);
expect(hasClass(".run-status")).toBe(true);
expect(hasClass(".run-details")).toBe(true);
expect(hasClass(".config-tab")).toBe(true);
expect(hasClass(".config-section")).toBe(true);
expect(hasClass(".config-description")).toBe(true);
expect(hasClass(".config-fields")).toBe(true);
expect(hasClass(".config-field")).toBe(true);
expect(hasClass(".config-hint")).toBe(true);
expect(hasClass(".config-error")).toBe(true);
expect(hasClass(".config-actions")).toBe(true);
expect(hasClass(".config-saved-indicator")).toBe(true);
expect(hasClass(".input--error")).toBe(true);
});
// Verify ActiveAgentsPanel classes
it("should define ActiveAgentsPanel CSS classes", () => {
expect(hasClass(".active-agents-panel")).toBe(true);
expect(hasClass(".active-agents-panel-header")).toBe(true);
expect(hasClass(".active-agents-grid")).toBe(true);
expect(hasClass(".live-agent-card")).toBe(true);
expect(hasClass(".live-agent-card-header")).toBe(true);
expect(hasClass(".live-agent-card-name")).toBe(true);
expect(hasClass(".live-agent-pulse")).toBe(true);
expect(hasClass(".live-agent-task")).toBe(true);
expect(hasClass(".live-agent-card-transcript")).toBe(true);
expect(hasClass(".live-agent-card-empty")).toBe(true);
expect(hasClass(".live-agent-card-line")).toBe(true);
expect(hasClass(".live-agent-card-footer")).toBe(true);
expect(hasClass(".live-agent-streaming-dot")).toBe(true);
});
// Verify NewAgentDialog classes
it("should define NewAgentDialog CSS classes", () => {
expect(hasClass(".agent-dialog-overlay")).toBe(true);
expect(hasClass(".agent-dialog")).toBe(true);
expect(hasClass(".agent-dialog-header")).toBe(true);
expect(hasClass(".agent-dialog-header-title")).toBe(true);
expect(hasClass(".agent-dialog-body")).toBe(true);
expect(hasClass(".agent-dialog-footer")).toBe(true);
expect(hasClass(".agent-dialog-steps")).toBe(true);
expect(hasClass(".agent-dialog-step")).toBe(true);
expect(hasClass(".agent-dialog-field")).toBe(true);
expect(hasClass(".agent-role-grid")).toBe(true);
expect(hasClass(".agent-role-option")).toBe(true);
expect(hasClass(".agent-role-option-icon")).toBe(true);
expect(hasClass(".agent-role-option-label")).toBe(true);
expect(hasClass(".agent-dialog-summary")).toBe(true);
expect(hasClass(".agent-dialog-summary-row")).toBe(true);
expect(hasClass(".agent-dialog-summary-row-label")).toBe(true);
expect(hasClass(".agent-dialog-summary-row-value")).toBe(true);
expect(hasClass(".agent-dialog-required")).toBe(true);
expect(hasClass(".agent-dialog-optional")).toBe(true);
expect(hasClass(".agent-dialog-error")).toBe(true);
expect(hasClass(".agent-dialog-info")).toBe(true);
expect(hasClass(".agent-dialog-loading")).toBe(true);
});
// Verify no inline <style> blocks remain in agent components
it("should not have inline <style> blocks in AgentsView", () => {
expect(agentsViewContent).not.toContain("<style>");
});
it("should not have inline <style> blocks in AgentDetailView", () => {
expect(agentDetailViewContent).not.toContain("<style>");
});
it("should not have inline <style> blocks in ActiveAgentsPanel", () => {
expect(activeAgentsPanelContent).not.toContain("<style>");
});
// Verify no inline style={{}} in NewAgentDialog
it("should not have inline style={{}} attributes in NewAgentDialog", () => {
const inlineStyleCount = (newAgentDialogContent.match(/style=\{\{/g) || []).length;
expect(inlineStyleCount).toBe(0);
});
});

View File

@@ -407,184 +407,6 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
)}
</div>
</div>
<style>{`
.agent-detail-overlay {
/* Agent state CSS variables - define fallback values */
--state-idle-bg: rgba(139, 148, 158, 0.15);
--state-idle-text: #8b949e;
--state-idle-border: #8b949e;
--state-active-bg: rgba(46, 160, 67, 0.15);
--state-active-text: #3fb950;
--state-active-border: #3fb950;
--state-paused-bg: rgba(227, 179, 65, 0.15);
--state-paused-text: #e3b541;
--state-paused-border: #e3b541;
--state-error-bg: rgba(248, 81, 73, 0.15);
--state-error-text: #f85149;
--state-error-border: #f85149;
--text-secondary: var(--text-muted, #8b949e);
/* Component-local aliases for dashboard tokens */
--bg-primary: var(--surface, #161b22);
--accent: var(--todo, #58a6ff);
--text-primary: var(--text, #e6edf3);
--bg-hover: var(--card-hover, #282e36);
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.agent-detail-modal {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 100%;
max-width: 900px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.agent-detail-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 60px;
color: var(--text-muted);
}
.agent-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.agent-detail-title {
display: flex;
align-items: center;
gap: 16px;
}
.agent-detail-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-lg, 12px);
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.agent-detail-info h2 {
font-size: 20px;
font-weight: 600;
margin: 0 0 6px 0;
}
.agent-detail-badges {
display: flex;
gap: 8px;
}
.agent-detail-actions {
display: flex;
align-items: center;
gap: 8px;
}
.agent-detail-tabs {
display: flex;
gap: 4px;
padding: 0 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.agent-detail-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
}
.agent-detail-tab:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.agent-detail-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.agent-detail-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.agent-detail-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
font-size: 12px;
color: var(--text-muted);
}
.agent-detail-id {
font-family: var(--font-mono);
cursor: pointer;
}
.agent-detail-id:hover {
color: var(--text-primary);
}
.divider {
color: var(--border);
}
.text-muted {
color: var(--text-muted);
}
.link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--accent);
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
`}</style>
</div>
);
}
@@ -711,107 +533,6 @@ function DashboardTab({
</pre>
</div>
)}
<style>{`
.dashboard-tab {
display: flex;
flex-direction: column;
gap: 24px;
}
.dashboard-section {
background: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
}
.dashboard-section h3 {
font-size: 14px;
font-weight: 600;
margin: 0 0 16px 0;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 12px;
color: var(--text-muted);
}
.info-value {
font-size: 14px;
font-weight: 500;
}
.inline-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
text-transform: capitalize;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.stat-card {
background: var(--bg-primary);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.current-task {
display: flex;
align-items: center;
gap: 12px;
}
.task-badge {
font-family: var(--font-mono);
background: var(--bg-primary);
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
}
.metadata-json {
background: var(--bg-primary);
padding: 12px;
border-radius: 4px;
font-size: 12px;
overflow-x: auto;
margin: 0;
}
`}</style>
</div>
);
}
@@ -838,28 +559,6 @@ function LogsTab({
<p className="text-muted">
Agent logs are available when the agent is assigned to a task
</p>
<style>{`
.logs-tab {
display: flex;
flex-direction: column;
height: 100%;
}
.logs-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--text-muted);
text-align: center;
}
.logs-empty p {
margin: 8px 0 0 0;
}
`}</style>
</div>
</div>
);
@@ -896,72 +595,6 @@ function LogsTab({
})
)}
</div>
<style>{`
.logs-tab {
display: flex;
flex-direction: column;
height: 100%;
}
.logs-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.logs-count {
font-size: 12px;
color: var(--text-muted);
}
.streaming-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--color-success, #3fb950);
}
.streaming-dot {
width: 8px;
height: 8px;
background: var(--color-success, #3fb950);
border-radius: 50%;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.logs-container {
flex: 1;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
max-height: 400px;
}
.logs-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--text-muted);
text-align: center;
}
.logs-empty p {
margin: 8px 0 0 0;
}
`}</style>
</div>
);
}
@@ -1020,43 +653,6 @@ function LogEntry({ entry, showTimestamp }: { entry: AgentLogEntry; showTimestam
<span className="log-detail"> {entry.detail}</span>
)}
</span>
<style>{`
.log-entry {
display: flex;
gap: 8px;
padding: 4px 8px;
margin: 2px 0;
border-radius: 4px;
}
.log-timestamp {
color: var(--text-muted);
font-size: 11px;
flex-shrink: 0;
}
.log-agent {
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
flex-shrink: 0;
}
.log-icon {
flex-shrink: 0;
}
.log-text {
word-break: break-word;
}
.log-detail {
color: var(--text-muted);
font-size: 12px;
}
`}</style>
</div>
);
}
@@ -1078,21 +674,6 @@ function RunsTab({
<Activity size={48} opacity={0.3} />
<p>No runs yet</p>
<p className="text-muted">Heartbeat runs will appear here</p>
<style>{`
.runs-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--text-muted);
text-align: center;
}
.runs-empty p {
margin: 8px 0 0 0;
}
`}</style>
</div>
);
}
@@ -1145,86 +726,6 @@ function RunsTab({
</div>
);
})}
<style>{`
.runs-tab {
display: flex;
flex-direction: column;
gap: 12px;
}
.run-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.run-card--active {
border-color: var(--cyan, #06b6d4);
background: rgba(6, 182, 212, 0.05);
}
.run-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.run-live-indicator {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: var(--cyan, #06b6d4);
}
.live-dot {
width: 8px;
height: 8px;
background: var(--cyan, #06b6d4);
border-radius: 50%;
animation: pulse 1.5s infinite;
}
.run-id {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-muted);
}
.run-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
text-transform: capitalize;
}
.run-status.active {
color: var(--cyan, #06b6d4);
}
.run-status.completed {
color: var(--color-success, #3fb950);
}
.run-status.failed {
color: var(--color-error, #f85149);
}
.run-status.terminated {
color: var(--text-muted);
}
.run-details {
display: flex;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
}
`}</style>
</div>
);
}
@@ -1534,81 +1035,6 @@ function ConfigTab({
)}
</div>
</div>
<style>{`
.config-tab {
display: flex;
flex-direction: column;
gap: 24px;
}
.config-section {
background: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
}
.config-section h3 {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px 0;
}
.config-description {
font-size: 14px;
color: var(--text-muted);
margin: 0 0 20px 0;
}
.config-fields {
display: flex;
flex-direction: column;
gap: 16px;
}
.config-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.config-field label {
font-size: 13px;
font-weight: 500;
}
.config-hint {
font-size: 11px;
color: var(--text-muted);
font-style: italic;
}
.config-error {
font-size: 11px;
color: var(--color-error, #f85149);
}
.input--error {
border-color: var(--color-error, #f85149) !important;
}
.config-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.config-saved-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--color-success, #3fb950);
}
`}</style>
</div>
);
}

View File

@@ -585,385 +585,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
/>
)}
<style>{`
.agents-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
/* Agent state CSS variables - define fallback values */
--state-idle-bg: rgba(139, 148, 158, 0.15);
--state-idle-text: #8b949e;
--state-idle-border: #8b949e;
--state-active-bg: rgba(46, 160, 67, 0.15);
--state-active-text: #3fb950;
--state-active-border: #3fb950;
--state-paused-bg: rgba(227, 179, 65, 0.15);
--state-paused-text: #e3b541;
--state-paused-border: #e3b541;
--state-error-bg: rgba(248, 81, 73, 0.15);
--state-error-text: #f85149;
--state-error-border: #f85149;
--text-secondary: var(--text-muted, #8b949e);
}
.agents-view-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: var(--bg-primary);
}
.agents-view-title {
display: flex;
align-items: center;
gap: 8px;
}
.agents-view-title h2 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.agents-view-controls {
display: flex;
align-items: center;
gap: 8px;
}
.agents-view-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.agent-controls {
display: flex;
gap: 12px;
margin-bottom: 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;
}
.agent-create-form {
display: flex;
gap: 12px;
margin-bottom: 16px;
padding: 16px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
.agent-create-form .input {
flex: 1;
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 {
display: flex;
flex-direction: column;
gap: 12px;
}
.agent-board {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.agent-board-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-top-width: 3px;
border-radius: 8px;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.agent-board-card:hover {
background: var(--card-hover);
border-color: var(--text-muted);
}
.agent-board-header {
display: flex;
align-items: center;
gap: 8px;
}
.agent-board-icon {
font-size: 20px;
line-height: 1;
}
.agent-board-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 4px;
margin-left: auto;
}
.agent-board-health {
display: flex;
align-items: center;
}
.agent-board-name {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-board-id {
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-secondary);
}
.agent-board-clickable {
cursor: pointer;
}
.agent-board-clickable:hover .agent-board-name {
color: var(--accent);
}
.agent-board-actions {
display: flex;
gap: 6px;
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.agent-board-actions .btn {
flex: 1;
justify-content: center;
}
.agent-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px;
color: var(--text-secondary);
}
.agent-card {
border: 1px solid var(--border);
border-left-width: 4px;
border-radius: 8px;
padding: 16px;
background: var(--bg-primary);
}
.agent-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.agent-info {
display: flex;
align-items: center;
gap: 12px;
}
.agent-icon {
font-size: 24px;
}
.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, #58a6ff);
outline-offset: 2px;
border-radius: 4px;
}
.agent-role-select {
font-size: 14px;
padding: 4px 8px;
min-width: 120px;
width: auto;
cursor: pointer;
}
.agent-meta {
display: flex;
flex-direction: column;
}
.agent-info--clickable {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 4px;
margin: -4px;
border-radius: 4px;
transition: background 0.15s ease;
}
.agent-info--clickable:hover {
background: var(--bg-hover);
}
.agent-info--clickable:hover .agent-name {
color: var(--accent);
}
.agent-card-chevron {
color: var(--text-muted);
margin-left: auto;
opacity: 0;
transition: opacity 0.15s ease;
}
.agent-info--clickable:hover .agent-card-chevron {
opacity: 1;
}
.agent-name {
font-weight: 600;
font-size: 16px;
}
.agent-id {
font-size: 12px;
font-family: var(--font-mono);
}
.agent-badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.agent-card-body {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
padding: 8px;
background: var(--bg-secondary);
border-radius: 4px;
font-size: 13px;
}
.agent-task,
.agent-heartbeat {
display: flex;
gap: 8px;
}
.agent-card-actions {
display: flex;
gap: 8px;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.text-secondary {
color: var(--text-secondary);
}
`}</style>
</div>
);
}

View File

@@ -159,17 +159,15 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
const selectedRole = AGENT_ROLES.find(r => r.value === role);
return (
<>
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
<div className="agent-dialog" role="dialog" aria-modal="true" aria-label="Create new agent">
{/* Header */}
<div className="agent-dialog-header">
<span style={{ fontWeight: 600, fontSize: 15 }}>New Agent</span>
<span className="agent-dialog-header-title">New Agent</span>
<button
className="btn-icon"
onClick={handleClose}
aria-label="Close"
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)", fontSize: 18, lineHeight: 1 }}
>
×
</button>
@@ -191,7 +189,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
{step === 0 && (
<div>
<div className="agent-dialog-field">
<label htmlFor="agent-name">Name <span style={{ color: "var(--state-error-text, #f85149)" }}>*</span></label>
<label htmlFor="agent-name">Name <span className="agent-dialog-required">*</span></label>
<input
id="agent-name"
type="text"
@@ -200,11 +198,10 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
value={name}
onChange={e => setName(e.target.value)}
autoFocus
style={{ width: "100%", boxSizing: "border-box" }}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-title">Title <span style={{ color: "var(--text-muted)", fontWeight: 400 }}>(optional)</span></label>
<label htmlFor="agent-title">Title <span className="agent-dialog-optional">(optional)</span></label>
<input
id="agent-title"
type="text"
@@ -212,7 +209,6 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
placeholder="e.g. Senior Code Reviewer"
value={title}
onChange={e => setTitle(e.target.value)}
style={{ width: "100%", boxSizing: "border-box" }}
/>
</div>
<div className="agent-dialog-field">
@@ -226,23 +222,22 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
onClick={() => setRole(r.value)}
>
<span className="agent-role-option-icon">{r.icon}</span>
<span style={{ fontSize: 12, marginTop: 4 }}>{r.label}</span>
<span className="agent-role-option-label">{r.label}</span>
</button>
))}
</div>
</div>
{/* AI-assisted generation */}
<div style={{ marginTop: 8, borderTop: "1px solid var(--border)", paddingTop: 12 }}>
<div className="agent-dialog-ai-generate">
<button
type="button"
className="btn"
className="btn btn--ai-generate"
onClick={() => setIsGenerationModalOpen(true)}
style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: 6 }}
>
<span></span>
Generate with AI
</button>
<p style={{ color: "var(--text-muted)", fontSize: 11, textAlign: "center", margin: "6px 0 0" }}>
<p className="agent-dialog-ai-hint">
Describe your agent&apos;s role and let AI generate a specification
</p>
</div>
@@ -254,7 +249,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
<div className="agent-dialog-field">
<label>Model</label>
{modelsLoading ? (
<div style={{ color: "var(--text-muted)", fontSize: 13, padding: "8px 0" }}>Loading models</div>
<div className="agent-dialog-loading">Loading models</div>
) : (
<CustomModelDropdown
id="agent-model"
@@ -277,7 +272,6 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
className="select"
value={runtimeConfig.thinkingLevel}
onChange={e => setRuntimeConfig(c => ({ ...c, thinkingLevel: e.target.value as ThinkingLevel }))}
style={{ width: "100%" }}
>
<option value="off">Off</option>
<option value="minimal">Minimal</option>
@@ -296,7 +290,6 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
max={500}
value={runtimeConfig.maxTurns}
onChange={e => setRuntimeConfig(c => ({ ...c, maxTurns: Math.max(1, parseInt(e.target.value, 10) || 1) }))}
style={{ width: "100%", boxSizing: "border-box" }}
/>
</div>
</div>
@@ -304,30 +297,30 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
{step === 2 && (
<div>
<p style={{ color: "var(--text-muted)", fontSize: 13, marginTop: 0, marginBottom: 12 }}>
<p className="agent-dialog-info">
Review your agent configuration before creating.
</p>
<div className="agent-dialog-summary">
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Name</span>
<span style={{ fontWeight: 600 }}>
{icon && <span style={{ marginRight: 6 }}>{icon}</span>}
<span className="agent-dialog-summary-row-label">Name</span>
<span className="agent-dialog-summary-row-value">
{icon && <span className="agent-dialog-icon-prefix">{icon}</span>}
{name}
</span>
</div>
{title && (
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Title</span>
<span className="agent-dialog-summary-row-label">Title</span>
<span>{title}</span>
</div>
)}
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Role</span>
<span className="agent-dialog-summary-row-label">Role</span>
<span>{selectedRole?.icon} {selectedRole?.label}</span>
</div>
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Model</span>
<span style={{ fontSize: 13 }}>
<span className="agent-dialog-summary-row-label">Model</span>
<span>
{selectedModel ? (
<>
<ProviderIcon provider={selectedModel.split("/")[0]} size="sm" />
@@ -341,21 +334,21 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
})()}
</>
) : (
<em style={{ color: "var(--text-muted)" }}>default</em>
<em className="agent-dialog-summary-row-value--muted">default</em>
)}
</span>
</div>
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Thinking</span>
<span style={{ textTransform: "capitalize" }}>{runtimeConfig.thinkingLevel}</span>
<span className="agent-dialog-summary-row-label">Thinking</span>
<span className="agent-dialog-summary-row-value--capitalize">{runtimeConfig.thinkingLevel}</span>
</div>
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Max Turns</span>
<span className="agent-dialog-summary-row-label">Max Turns</span>
<span>{runtimeConfig.maxTurns}</span>
</div>
</div>
{error && (
<p style={{ color: "var(--state-error-text, #f85149)", fontSize: 13, marginTop: 12 }}>{error}</p>
<p className="agent-dialog-error">{error}</p>
)}
</div>
)}
@@ -390,7 +383,6 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
)}
</div>
</div>
</div>
{/* AI-assisted agent generation modal */}
<AgentGenerationModal
@@ -399,6 +391,6 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
onGenerated={handleGenerated}
projectId={projectId}
/>
</>
</div>
);
}

View File

@@ -76,7 +76,7 @@ describe("AgentDetailView", () => {
expect(screen.getByText(/Loading agent/i)).toBeInTheDocument();
});
it("defines CSS variables for agent state tokens in the style block", async () => {
it("defines CSS variables for agent state tokens in the global stylesheet", async () => {
render(
<AgentDetailView
agentId="agent-001"
@@ -90,15 +90,18 @@ describe("AgentDetailView", () => {
expect(headings.some(h => h.textContent === "Test Agent")).toBe(true);
});
// Verify state CSS variables are defined in the component's style block
const styleElements = document.querySelectorAll("style");
const allCss = Array.from(styleElements).map(el => el.textContent ?? "").join("");
expect(allCss).toContain("--state-idle-bg");
expect(allCss).toContain("--state-active-bg");
expect(allCss).toContain("--state-paused-bg");
expect(allCss).toContain("--state-error-bg");
expect(allCss).toContain("--state-idle-text");
expect(allCss).toContain("--state-active-text");
// Verify state CSS variables are defined in the global stylesheet (styles.css)
// (previously these were in inline style blocks, now they're in the global :root)
const fs = await import("fs");
const path = await import("path");
const stylesPath = path.join(__dirname, "../../styles.css");
const stylesContent = fs.readFileSync(stylesPath, "utf-8");
expect(stylesContent).toContain("--state-idle-bg:");
expect(stylesContent).toContain("--state-active-bg:");
expect(stylesContent).toContain("--state-paused-bg:");
expect(stylesContent).toContain("--state-error-bg:");
expect(stylesContent).toContain("--state-idle-text:");
expect(stylesContent).toContain("--state-active-text:");
});
it("uses token-based state colors for badges instead of hardcoded hex", async () => {
@@ -155,7 +158,7 @@ describe("AgentDetailView", () => {
});
});
it("uses token-based color references in CSS instead of undefined vars", async () => {
it("uses token-based color references for success and error states", async () => {
render(
<AgentDetailView
agentId="agent-001"
@@ -168,20 +171,20 @@ describe("AgentDetailView", () => {
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
// Navigate to Runs tab to trigger rendering of run-related style blocks
// Navigate to Runs tab to trigger rendering of run-related content
fireEvent.click(screen.getByText("Runs"));
// Verify style blocks use --color-success and --color-error with fallbacks
// (not bare --success or --error which are undefined in the root CSS)
await waitFor(() => {
const styleElements = document.querySelectorAll("style");
const allCss = Array.from(styleElements).map(el => el.textContent ?? "").join("");
expect(allCss).toMatch(/var\(--color-success/);
expect(allCss).toMatch(/var\(--color-error/);
});
// Verify that the global stylesheet defines --color-success and --color-error
// (previously checked in inline style blocks, now verified by reading styles.css)
const fs = await import("fs");
const path = await import("path");
const stylesPath = path.join(__dirname, "../../styles.css");
const stylesContent = fs.readFileSync(stylesPath, "utf-8");
expect(stylesContent).toMatch(/--color-success:/);
expect(stylesContent).toMatch(/--color-error:/);
});
it("defines component-local aliases for undefined CSS tokens", async () => {
it("uses global design tokens instead of component-local aliases", async () => {
render(
<AgentDetailView
agentId="agent-001"
@@ -194,14 +197,18 @@ describe("AgentDetailView", () => {
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
// Verify that component-local aliases are defined for tokens used in the style block
// These map to real global tokens so they don't fall back to browser defaults
const styleElements = document.querySelectorAll("style");
const allCss = Array.from(styleElements).map(el => el.textContent ?? "").join("");
expect(allCss).toContain("--bg-primary: var(--surface");
expect(allCss).toContain("--accent: var(--todo");
expect(allCss).toContain("--text-primary: var(--text");
expect(allCss).toContain("--bg-hover: var(--card-hover");
// Previously the component defined local aliases like --bg-primary, --accent, etc.
// Now these are replaced with direct global token references in the CSS classes.
// Verify the global stylesheet defines the real tokens that the component uses.
const fs = await import("fs");
const path = await import("path");
const stylesPath = path.join(__dirname, "../../styles.css");
const stylesContent = fs.readFileSync(stylesPath, "utf-8");
// The component classes now use --surface, --todo, --text, --card-hover directly
expect(stylesContent).toMatch(/--surface:/);
expect(stylesContent).toMatch(/--todo:/);
expect(stylesContent).toMatch(/--text:/);
expect(stylesContent).toMatch(/--card-hover:/);
});
it("displays agent name in header after loading", async () => {

File diff suppressed because it is too large Load Diff