feat: color badges for subagent types with .claude/agents/ config support
Subagent badges now show distinct colors instead of generic gray. Colors are resolved from the project's .claude/agents/*.md frontmatter (color field), with deterministic hash-based fallback for unconfigured types. New AgentConfigReader service reads agent definitions via IPC, cached per project root to avoid redundant disk reads on session refreshes. Team member colors remain unaffected (team branch has priority).
This commit is contained in:
parent
f85c308672
commit
f05bf9fac4
12 changed files with 400 additions and 13 deletions
|
|
@ -14,7 +14,7 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
|
||||
import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
|
||||
import { validateFilePath } from '../utils/pathValidation';
|
||||
import { countTokens } from '../utils/tokenizer';
|
||||
|
||||
|
|
@ -123,4 +123,15 @@ export function registerUtilityRoutes(app: FastifyInstance): void {
|
|||
app.post<{ Body: { url: string } }>('/api/open-external', async () => {
|
||||
return { success: false, error: 'Not available in browser mode' };
|
||||
});
|
||||
|
||||
// Read agent configs
|
||||
app.post<{ Body: { projectRoot: string } }>('/api/read-agent-configs', async (request) => {
|
||||
try {
|
||||
const { projectRoot } = request.body;
|
||||
return await readAgentConfigs(projectRoot);
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/read-agent-configs:', error);
|
||||
return {};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
|
||||
import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
|
||||
import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
|
||||
|
||||
import type { AgentConfig } from '@shared/types/api';
|
||||
|
||||
const logger = createLogger('IPC:utility');
|
||||
import { validateFilePath, validateOpenPath } from '../utils/pathValidation';
|
||||
|
|
@ -28,6 +30,7 @@ export function registerUtilityHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles);
|
||||
ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd);
|
||||
ipcMain.handle('read-mentioned-file', handleReadMentionedFile);
|
||||
ipcMain.handle('read-agent-configs', handleReadAgentConfigs);
|
||||
|
||||
logger.info('Utility handlers registered');
|
||||
}
|
||||
|
|
@ -42,6 +45,7 @@ export function removeUtilityHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler('read-claude-md-files');
|
||||
ipcMain.removeHandler('read-directory-claude-md');
|
||||
ipcMain.removeHandler('read-mentioned-file');
|
||||
ipcMain.removeHandler('read-agent-configs');
|
||||
|
||||
logger.info('Utility handlers removed');
|
||||
}
|
||||
|
|
@ -228,3 +232,19 @@ async function handleReadMentionedFile(
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for 'read-agent-configs' IPC call.
|
||||
* Reads agent definitions from project's .claude/agents/ directory.
|
||||
*/
|
||||
async function handleReadAgentConfigs(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectRoot: string
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
try {
|
||||
return await readAgentConfigs(projectRoot);
|
||||
} catch (error) {
|
||||
logger.error('Error in read-agent-configs:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
75
src/main/services/parsing/AgentConfigReader.ts
Normal file
75
src/main/services/parsing/AgentConfigReader.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Agent Config Reader
|
||||
*
|
||||
* Reads `.claude/agents/*.md` files from a project directory and extracts
|
||||
* frontmatter metadata (name, color) for use in subagent visualization.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { AgentConfig } from '@shared/types/api';
|
||||
|
||||
const logger = createLogger('AgentConfigReader');
|
||||
|
||||
/**
|
||||
* Parse simple YAML frontmatter from markdown content.
|
||||
* Only extracts top-level scalar key: value pairs between --- delimiters.
|
||||
*/
|
||||
function parseFrontmatter(content: string): Record<string, string> {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
|
||||
if (!match) return {};
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of match[1].split('\n')) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
// Strip surrounding quotes
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (key) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read agent config files from a project's `.claude/agents/` directory.
|
||||
* Returns a map of agent name → config (with optional color).
|
||||
*/
|
||||
export async function readAgentConfigs(
|
||||
projectRoot: string
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
const agentsDir = path.join(projectRoot, '.claude', 'agents');
|
||||
const result: Record<string, AgentConfig> = {};
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(agentsDir);
|
||||
const mdFiles = entries.filter((f) => f.endsWith('.md'));
|
||||
|
||||
await Promise.all(
|
||||
mdFiles.map(async (filename) => {
|
||||
try {
|
||||
const content = await fs.promises.readFile(path.join(agentsDir, filename), 'utf8');
|
||||
const frontmatter = parseFrontmatter(content);
|
||||
const name = frontmatter.name || filename.replace(/\.md$/, '');
|
||||
const config: AgentConfig = { name };
|
||||
if (frontmatter.color) {
|
||||
config.color = frontmatter.color;
|
||||
}
|
||||
result[name] = config;
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// Directory doesn't exist or unreadable — normal for projects without custom agents
|
||||
logger.debug(`No agents directory at ${agentsDir}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
* - GitIdentityResolver: Resolves git identities from sessions
|
||||
*/
|
||||
|
||||
export * from './AgentConfigReader';
|
||||
export * from './ClaudeMdReader';
|
||||
export * from './GitIdentityResolver';
|
||||
export * from './MessageClassifier';
|
||||
|
|
|
|||
|
|
@ -169,6 +169,10 @@ const electronAPI: ElectronAPI = {
|
|||
readMentionedFile: (absolutePath: string, projectRoot: string, maxTokens?: number) =>
|
||||
ipcRenderer.invoke('read-mentioned-file', absolutePath, projectRoot, maxTokens),
|
||||
|
||||
// Agent config reading
|
||||
readAgentConfigs: (projectRoot: string) =>
|
||||
ipcRenderer.invoke('read-agent-configs', projectRoot),
|
||||
|
||||
// Notifications API
|
||||
notifications: {
|
||||
get: (options?: { limit?: number; offset?: number }) =>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import type {
|
|||
WaterfallData,
|
||||
WslClaudeRootCandidate,
|
||||
} from '@shared/types';
|
||||
import type { AgentConfig } from '@shared/types/api';
|
||||
|
||||
export class HttpAPIClient implements ElectronAPI {
|
||||
private baseUrl: string;
|
||||
|
|
@ -309,6 +310,13 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
maxTokens,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent config reading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
readAgentConfigs = (projectRoot: string): Promise<Record<string, AgentConfig>> =>
|
||||
this.post<Record<string, AgentConfig>>('/api/read-agent-configs', { projectRoot });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifications (nested API)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -11,11 +11,8 @@ import {
|
|||
CARD_TEXT_LIGHTER,
|
||||
COLOR_TEXT_MUTED,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
TAG_BG,
|
||||
TAG_BORDER,
|
||||
TAG_TEXT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getSubagentTypeColorSet, getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
|
||||
|
|
@ -78,8 +75,13 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
const subagentType = subagent.subagentType ?? 'Task';
|
||||
const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description;
|
||||
|
||||
// Agent configs from .claude/agents/ for color lookup
|
||||
const agentConfigs = useStore((s) => s.agentConfigs);
|
||||
|
||||
// Team member colors (when this subagent is a team member)
|
||||
const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null;
|
||||
// Type-based colors for non-team subagents (from agent config or deterministic hash)
|
||||
const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null;
|
||||
|
||||
// Detect shutdown-only team activations (trivial: just a shutdown_response)
|
||||
const isShutdownOnly = useMemo(() => {
|
||||
|
|
@ -285,11 +287,11 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
style={{ color: CARD_ICON_MUTED }}
|
||||
/>
|
||||
|
||||
{/* Icon - colored dot for team members, Bot icon for regular subagents */}
|
||||
{teamColors ? (
|
||||
{/* Icon - colored dot for team members/typed subagents, Bot icon for generic */}
|
||||
{teamColors || typeColors ? (
|
||||
<span
|
||||
className="size-3.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: teamColors.border }}
|
||||
style={{ backgroundColor: (teamColors ?? typeColors)!.border }}
|
||||
/>
|
||||
) : (
|
||||
<Bot
|
||||
|
|
@ -298,7 +300,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Type badge - team member name or generic type */}
|
||||
{/* Type badge - team member name or typed subagent */}
|
||||
{teamColors && subagent.team ? (
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
|
|
@ -314,9 +316,9 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide"
|
||||
style={{
|
||||
backgroundColor: TAG_BG,
|
||||
color: TAG_TEXT,
|
||||
border: `1px solid ${TAG_BORDER}`,
|
||||
backgroundColor: typeColors!.badge,
|
||||
color: typeColors!.text,
|
||||
border: `1px solid ${typeColors!.border}40`,
|
||||
}}
|
||||
>
|
||||
{subagentType}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,30 @@ const DEFAULT_COLOR: TeamColorSet = TEAMMATE_COLORS.blue;
|
|||
* Get a TeamColorSet from a color name or hex string.
|
||||
* Falls back to blue if unrecognized.
|
||||
*/
|
||||
const COLOR_NAMES = Object.keys(TEAMMATE_COLORS);
|
||||
|
||||
function hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 31 + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function getSubagentTypeColorSet(
|
||||
subagentType: string,
|
||||
agentConfigs?: Record<string, { color?: string }>
|
||||
): TeamColorSet {
|
||||
// Use color from agent config if available
|
||||
const configColor = agentConfigs?.[subagentType]?.color;
|
||||
if (configColor) {
|
||||
return getTeamColorSet(configColor);
|
||||
}
|
||||
// Fallback: deterministic hash-based color
|
||||
const index = hashString(subagentType) % COLOR_NAMES.length;
|
||||
return TEAMMATE_COLORS[COLOR_NAMES[index]];
|
||||
}
|
||||
|
||||
export function getTeamColorSet(colorName: string): TeamColorSet {
|
||||
if (!colorName) return DEFAULT_COLOR;
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const sessionRefreshGeneration = new Map<string, number>();
|
|||
const sessionRefreshInFlight = new Set<string>();
|
||||
const sessionRefreshQueued = new Set<string>();
|
||||
let sessionDetailFetchGeneration = 0;
|
||||
let agentConfigsCachedForProject = '';
|
||||
|
||||
import { getAllTabs } from '../utils/paneHelpers';
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ import type {
|
|||
} from '@renderer/types/contextInjection';
|
||||
import type { ClaudeMdFileInfo, SessionDetail } from '@renderer/types/data';
|
||||
import type { AIGroup, SessionConversation } from '@renderer/types/groups';
|
||||
import type { AgentConfig } from '@shared/types/api';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -92,6 +94,9 @@ export interface SessionDetailSlice {
|
|||
// Context phase info (compaction boundaries)
|
||||
sessionPhaseInfo: ContextPhaseInfo | null;
|
||||
|
||||
// Agent configs from .claude/agents/ (keyed by agent name)
|
||||
agentConfigs: Record<string, AgentConfig>;
|
||||
|
||||
// Visible AI Group
|
||||
visibleAIGroupId: string | null;
|
||||
selectedAIGroup: AIGroup | null;
|
||||
|
|
@ -133,6 +138,8 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
// Context phase info (compaction boundaries)
|
||||
sessionPhaseInfo: null,
|
||||
|
||||
agentConfigs: {},
|
||||
|
||||
visibleAIGroupId: null,
|
||||
selectedAIGroup: null,
|
||||
|
||||
|
|
@ -190,6 +197,18 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
let claudeMdStats: Map<string, ClaudeMdStats> | null = null;
|
||||
let contextStats: Map<string, ContextStats> | null = null;
|
||||
let phaseInfo: ContextPhaseInfo | null = null;
|
||||
// Fetch agent configs from .claude/agents/ (only when project changes)
|
||||
if (connectionMode !== 'ssh' && projectRoot && projectRoot !== agentConfigsCachedForProject) {
|
||||
try {
|
||||
const configs = await api.readAgentConfigs(projectRoot);
|
||||
if (requestGeneration !== sessionDetailFetchGeneration) return;
|
||||
agentConfigsCachedForProject = projectRoot;
|
||||
set({ agentConfigs: configs });
|
||||
} catch (err) {
|
||||
logger.error('Failed to read agent configs:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (connectionMode !== 'ssh' && conversation?.items) {
|
||||
// Fetch real CLAUDE.md token data
|
||||
let claudeMdTokenData: Record<string, ClaudeMdFileInfo> = {};
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ import type {
|
|||
SubagentDetail,
|
||||
} from '@main/types';
|
||||
|
||||
// =============================================================================
|
||||
// Agent Config
|
||||
// =============================================================================
|
||||
|
||||
export interface AgentConfig {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Notifications API
|
||||
// =============================================================================
|
||||
|
|
@ -366,6 +375,9 @@ export interface ElectronAPI {
|
|||
maxTokens?: number
|
||||
) => Promise<ClaudeMdFileInfo | null>;
|
||||
|
||||
// Agent config reading
|
||||
readAgentConfigs: (projectRoot: string) => Promise<Record<string, AgentConfig>>;
|
||||
|
||||
// Notifications API
|
||||
notifications: NotificationsAPI;
|
||||
|
||||
|
|
|
|||
95
test/main/services/parsing/AgentConfigReader.test.ts
Normal file
95
test/main/services/parsing/AgentConfigReader.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
import { readAgentConfigs } from '@main/services/parsing/AgentConfigReader';
|
||||
|
||||
describe('readAgentConfigs', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-config-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeAgent(filename: string, content: string): void {
|
||||
const agentsDir = path.join(tmpDir, '.claude', 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentsDir, filename), content);
|
||||
}
|
||||
|
||||
it('returns empty object when .claude/agents/ does not exist', async () => {
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('parses agent with name and color from frontmatter', async () => {
|
||||
writeAgent('test-agent.md', `---
|
||||
name: test-agent
|
||||
color: red
|
||||
---
|
||||
# Test agent
|
||||
`);
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(result).toEqual({
|
||||
'test-agent': { name: 'test-agent', color: 'red' },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses filename as name when frontmatter has no name field', async () => {
|
||||
writeAgent('my-agent.md', `---
|
||||
color: blue
|
||||
---
|
||||
# My Agent
|
||||
`);
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(result['my-agent']).toBeDefined();
|
||||
expect(result['my-agent'].color).toBe('blue');
|
||||
});
|
||||
|
||||
it('handles agents without color field', async () => {
|
||||
writeAgent('plain.md', `---
|
||||
name: plain
|
||||
description: "A plain agent"
|
||||
---
|
||||
Content
|
||||
`);
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(result.plain).toEqual({ name: 'plain' });
|
||||
expect(result.plain.color).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles agents without frontmatter', async () => {
|
||||
writeAgent('no-front.md', '# Just markdown\nNo frontmatter here.');
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(result['no-front']).toEqual({ name: 'no-front' });
|
||||
});
|
||||
|
||||
it('reads multiple agents', async () => {
|
||||
writeAgent('a.md', `---\nname: a\ncolor: green\n---\n`);
|
||||
writeAgent('b.md', `---\nname: b\ncolor: purple\n---\n`);
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
expect(result.a.color).toBe('green');
|
||||
expect(result.b.color).toBe('purple');
|
||||
});
|
||||
|
||||
it('strips quotes from frontmatter values', async () => {
|
||||
writeAgent('quoted.md', `---\nname: "quoted-agent"\ncolor: 'cyan'\n---\n`);
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(result['quoted-agent']).toEqual({ name: 'quoted-agent', color: 'cyan' });
|
||||
});
|
||||
|
||||
it('ignores non-md files', async () => {
|
||||
const agentsDir = path.join(tmpDir, '.claude', 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentsDir, 'readme.txt'), 'not an agent');
|
||||
writeAgent('real.md', `---\nname: real\ncolor: red\n---\n`);
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(Object.keys(result)).toEqual(['real']);
|
||||
});
|
||||
});
|
||||
116
test/renderer/constants/teamColors.test.ts
Normal file
116
test/renderer/constants/teamColors.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSubagentTypeColorSet, getTeamColorSet, TeamColorSet } from '@renderer/constants/teamColors';
|
||||
|
||||
function isValidColorSet(cs: TeamColorSet): boolean {
|
||||
return typeof cs.border === 'string' && typeof cs.badge === 'string' && typeof cs.text === 'string';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// getTeamColorSet
|
||||
// =============================================================================
|
||||
|
||||
describe('getTeamColorSet', () => {
|
||||
it('returns blue (default) for empty string', () => {
|
||||
const result = getTeamColorSet('');
|
||||
expect(result.border).toBe('#3b82f6');
|
||||
});
|
||||
|
||||
it('resolves named colors', () => {
|
||||
expect(getTeamColorSet('green').border).toBe('#22c55e');
|
||||
expect(getTeamColorSet('red').border).toBe('#ef4444');
|
||||
expect(getTeamColorSet('purple').border).toBe('#a855f7');
|
||||
});
|
||||
|
||||
it('is case-insensitive for named colors', () => {
|
||||
expect(getTeamColorSet('Green')).toEqual(getTeamColorSet('green'));
|
||||
expect(getTeamColorSet('BLUE')).toEqual(getTeamColorSet('blue'));
|
||||
});
|
||||
|
||||
it('generates a color set from hex strings', () => {
|
||||
const result = getTeamColorSet('#ff5500');
|
||||
expect(result.border).toBe('#ff5500');
|
||||
expect(result.badge).toBe('#ff550026');
|
||||
expect(result.text).toBe('#ff5500');
|
||||
});
|
||||
|
||||
it('falls back to blue for unknown non-hex strings', () => {
|
||||
const result = getTeamColorSet('nonexistent');
|
||||
expect(result.border).toBe('#3b82f6');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// getSubagentTypeColorSet
|
||||
// =============================================================================
|
||||
|
||||
describe('getSubagentTypeColorSet', () => {
|
||||
it('always returns a valid TeamColorSet without agent configs', () => {
|
||||
const types = ['test-agent', 'quality-fixer', 'Explore', 'Plan', 'my-custom-agent', 'anything'];
|
||||
for (const t of types) {
|
||||
const result = getSubagentTypeColorSet(t);
|
||||
expect(isValidColorSet(result)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('is deterministic — same input always returns same color', () => {
|
||||
const a = getSubagentTypeColorSet('my-custom-agent');
|
||||
const b = getSubagentTypeColorSet('my-custom-agent');
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('different types can produce different colors', () => {
|
||||
const results = new Set(
|
||||
['Explore', 'Plan', 'test-agent', 'quality-fixer', 'claude-md-auditor', 'Bash', 'general-purpose', 'statusline-setup']
|
||||
.map((t) => getSubagentTypeColorSet(t).border)
|
||||
);
|
||||
expect(results.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('uses color from agent config when available', () => {
|
||||
const configs = {
|
||||
'test-agent': { name: 'test-agent', color: 'red' },
|
||||
};
|
||||
const result = getSubagentTypeColorSet('test-agent', configs);
|
||||
// Should use the named "red" color from getTeamColorSet
|
||||
expect(result.border).toBe('#ef4444');
|
||||
expect(result.text).toBe('#f87171');
|
||||
});
|
||||
|
||||
it('uses hex color from agent config', () => {
|
||||
const configs = {
|
||||
'my-agent': { name: 'my-agent', color: '#ff00ff' },
|
||||
};
|
||||
const result = getSubagentTypeColorSet('my-agent', configs);
|
||||
expect(result.border).toBe('#ff00ff');
|
||||
});
|
||||
|
||||
it('falls back to hash when agent config has no color', () => {
|
||||
const configs = {
|
||||
'my-agent': { name: 'my-agent' },
|
||||
};
|
||||
const withConfig = getSubagentTypeColorSet('my-agent', configs);
|
||||
const withoutConfig = getSubagentTypeColorSet('my-agent');
|
||||
// Should be the same — both use hash fallback
|
||||
expect(withConfig).toEqual(withoutConfig);
|
||||
});
|
||||
|
||||
it('falls back to hash when agent type not in configs', () => {
|
||||
const configs = {
|
||||
'other-agent': { name: 'other-agent', color: 'green' },
|
||||
};
|
||||
const withConfig = getSubagentTypeColorSet('unknown-agent', configs);
|
||||
const withoutConfig = getSubagentTypeColorSet('unknown-agent');
|
||||
expect(withConfig).toEqual(withoutConfig);
|
||||
});
|
||||
|
||||
it('does not interfere with getTeamColorSet', () => {
|
||||
const teamGreen = getTeamColorSet('green');
|
||||
expect(teamGreen.border).toBe('#22c55e');
|
||||
|
||||
const configs = { green: { name: 'green', color: 'purple' } };
|
||||
getSubagentTypeColorSet('green', configs);
|
||||
// Team API remains unaffected
|
||||
expect(getTeamColorSet('green').border).toBe('#22c55e');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue