Merge pull request #50 from cesarafonseca/feat/subagent-type-color-badges
feat: color badges for subagent types with .claude/agents/ config support
This commit is contained in:
commit
39d88e22a4
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