feat: enhance CreateTeamDialog and member management features
- Updated CreateTeamDialog to include automatic team name suggestions and improved default team description handling. - Enhanced member management with new utility functions for building member draft suggestions and color maps. - Integrated task and team mention suggestions into MembersEditorSection and MemberDraftRow for better user experience. - Refactored UI components for improved styling and functionality, ensuring consistent behavior across team management features.
This commit is contained in:
parent
1d8191e7cc
commit
0df13e206b
9 changed files with 265 additions and 91 deletions
|
|
@ -830,8 +830,8 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, process
|
||||||
return `agent_teams_ui [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] — team does NOT exist yet. You must create it.
|
return `agent_teams_ui [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] — team does NOT exist yet. You must create it.
|
||||||
|
|
||||||
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
||||||
CRITICAL: Execute ALL steps directly yourself. Do NOT use the Agent tool to delegate provisioning to a sub-agent. The ONLY valid use of the Agent tool is spawning individual teammates in step 2.
|
CRITICAL: Execute ALL steps directly yourself in sequence. Do NOT delegate any step to a sub-agent via the Agent tool. The ONLY valid use of the Agent tool is spawning individual teammates in step 2.
|
||||||
CRITICAL: During this initial team provisioning turn, do NOT call mcp__agent-teams__team_launch or mcp__agent-teams__team_stop. This turn is only for creating/provisioning the team state and spawning teammates.
|
CRITICAL: For step 1, use the BUILT-IN TeamCreate tool — NOT any mcp__agent-teams__* MCP tool. Do NOT call mcp__agent-teams__team_launch, mcp__agent-teams__team_stop, or any other mcp__agent-teams__ runtime tool during provisioning. MCP board tools (task_create, task_set_status, etc.) are allowed only in step 3.
|
||||||
You are “${leadName}”, the team lead.
|
You are “${leadName}”, the team lead.
|
||||||
|
|
||||||
Goal: Create and provision a NEW Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}.
|
Goal: Create and provision a NEW Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}.
|
||||||
|
|
@ -841,7 +841,7 @@ ${persistentContext}
|
||||||
|
|
||||||
Steps (execute in this exact order — do NOT skip any step):
|
Steps (execute in this exact order — do NOT skip any step):
|
||||||
|
|
||||||
1) MANDATORY FIRST STEP: Call the TeamCreate tool with team_name=”${request.teamName}”. This creates the team config and in-memory state. Without this step, teammate spawns will FAIL. Do NOT assume the team already exists based on this prompt header.
|
1) MANDATORY FIRST STEP: Call the BUILT-IN TeamCreate tool (not any MCP tool) with team_name=”${request.teamName}”. This creates the team config and in-memory state. Without this step, teammate spawns will FAIL. Do NOT assume the team already exists.
|
||||||
|
|
||||||
${step2Block}
|
${step2Block}
|
||||||
|
|
||||||
|
|
@ -960,8 +960,8 @@ ${memberSpawnInstructions}
|
||||||
return `${startLabel} [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
|
return `${startLabel} [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
|
||||||
|
|
||||||
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
||||||
CRITICAL: Execute ALL steps directly yourself. Do NOT use the Agent tool to delegate work to a sub-agent. The ONLY valid use of the Agent tool is spawning individual teammates in step 2.
|
CRITICAL: Execute ALL steps directly yourself in sequence. Do NOT delegate any step to a sub-agent via the Agent tool. The ONLY valid use of the Agent tool is spawning individual teammates in step 2.
|
||||||
CRITICAL: During this initial team launch/reconnect turn, do NOT call mcp__agent-teams__team_launch or mcp__agent-teams__team_stop. This turn is only for reconnecting the existing team state and spawning teammates.
|
CRITICAL: Do NOT call mcp__agent-teams__team_launch, mcp__agent-teams__team_stop, or any other mcp__agent-teams__ runtime tool during this turn. MCP board tools (task_create, task_set_status, etc.) are allowed.
|
||||||
You are "${leadName}", the team lead.
|
You are "${leadName}", the team lead.
|
||||||
|
|
||||||
Goal: Reconnect with existing team "${request.teamName}" and resume pending work.
|
Goal: Reconnect with existing team "${request.teamName}" and resume pending work.
|
||||||
|
|
@ -4699,7 +4699,7 @@ export class TeamProvisioningService {
|
||||||
``,
|
``,
|
||||||
`You are "${leadName}", the team lead of team "${run.teamName}".`,
|
`You are "${leadName}", the team lead of team "${run.teamName}".`,
|
||||||
`You are running in a non-interactive CLI session. Do not ask questions.`,
|
`You are running in a non-interactive CLI session. Do not ask questions.`,
|
||||||
`CRITICAL: Execute ALL steps directly yourself. Do NOT use the Agent tool to delegate work to a sub-agent. The ONLY valid use of the Agent tool is spawning individual teammates.`,
|
`CRITICAL: Execute ALL steps directly yourself in sequence. Do NOT delegate any step to a sub-agent via the Agent tool. The ONLY valid use of the Agent tool is spawning individual teammates.`,
|
||||||
``,
|
``,
|
||||||
persistentContext,
|
persistentContext,
|
||||||
taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '',
|
taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import {
|
import {
|
||||||
|
buildMemberDraftColorMap,
|
||||||
|
buildMemberDraftSuggestions,
|
||||||
buildMembersFromDrafts,
|
buildMembersFromDrafts,
|
||||||
createMemberDraft,
|
createMemberDraft,
|
||||||
MembersEditorSection,
|
MembersEditorSection,
|
||||||
|
|
@ -25,10 +27,11 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
||||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
|
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
||||||
|
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
|
|
@ -37,6 +40,7 @@ import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
||||||
|
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
||||||
|
|
||||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||||
|
|
@ -52,7 +56,6 @@ const TEAM_COLOR_NAMES = [
|
||||||
'pink',
|
'pink',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
|
||||||
import type {
|
import type {
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
Project,
|
Project,
|
||||||
|
|
@ -97,10 +100,9 @@ interface ValidationResult {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
import { CUSTOM_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||||
const DEV_DEFAULT_TEAM = {
|
const DEV_DEFAULT_TEAM = {
|
||||||
teamName: 'team-alpha',
|
teamName: 'signal-ops',
|
||||||
description: 'Dev test team for provisioning flow',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string }[] = [
|
const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string }[] = [
|
||||||
|
|
@ -134,6 +136,13 @@ function validateTeamNameInline(name: string): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDefaultTeamDescription(teamName: string): string {
|
||||||
|
const trimmedName = teamName.trim();
|
||||||
|
return trimmedName.length > 0
|
||||||
|
? `${trimmedName} team for provisioning flow`
|
||||||
|
: 'Team for provisioning flow';
|
||||||
|
}
|
||||||
|
|
||||||
function validateRequest(
|
function validateRequest(
|
||||||
request: TeamCreateRequest,
|
request: TeamCreateRequest,
|
||||||
options?: { requireCwd?: boolean }
|
options?: { requireCwd?: boolean }
|
||||||
|
|
@ -224,6 +233,7 @@ export const CreateTeamDialog = ({
|
||||||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||||
const prepareRequestSeqRef = useRef(0);
|
const prepareRequestSeqRef = useRef(0);
|
||||||
|
const lastAutoDescriptionRef = useRef<string | null>(null);
|
||||||
const [fieldErrors, setFieldErrors] = useState<{
|
const [fieldErrors, setFieldErrors] = useState<{
|
||||||
teamName?: string;
|
teamName?: string;
|
||||||
members?: string;
|
members?: string;
|
||||||
|
|
@ -313,6 +323,7 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
const resetFormState = (): void => {
|
const resetFormState = (): void => {
|
||||||
setTeamName('');
|
setTeamName('');
|
||||||
|
lastAutoDescriptionRef.current = null;
|
||||||
descriptionDraft.clearDraft();
|
descriptionDraft.clearDraft();
|
||||||
promptDraft.clearDraft();
|
promptDraft.clearDraft();
|
||||||
promptChipDraft.clearChipDraft();
|
promptChipDraft.clearChipDraft();
|
||||||
|
|
@ -328,6 +339,7 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||||
const dialogTeamNameKey = sanitizeTeamName(teamName.trim());
|
const dialogTeamNameKey = sanitizeTeamName(teamName.trim());
|
||||||
|
const suggestedTeamName = getNextSuggestedTeamName(existingTeamNames);
|
||||||
|
|
||||||
// Clear stale provisioning error when dialog opens
|
// Clear stale provisioning error when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -469,16 +481,34 @@ export const CreateTeamDialog = ({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || initialData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTeamName((prev) => (prev.trim().length === 0 ? suggestedTeamName : prev));
|
||||||
|
}, [initialData, open, suggestedTeamName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !isDev || initialData) {
|
if (!open || !isDev || initialData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTeamName((prev) => (prev.trim().length === 0 ? DEV_DEFAULT_TEAM.teamName : prev));
|
const resolvedTeamName = teamName.trim() || suggestedTeamName;
|
||||||
if (descriptionDraft.value.trim().length === 0) {
|
const nextAutoDescription = buildDefaultTeamDescription(resolvedTeamName);
|
||||||
descriptionDraft.setValue(DEV_DEFAULT_TEAM.description);
|
const currentDescription = descriptionDraft.value.trim();
|
||||||
|
const previousAutoDescription = lastAutoDescriptionRef.current?.trim() ?? '';
|
||||||
|
const shouldSyncDescription =
|
||||||
|
currentDescription.length === 0 || currentDescription === previousAutoDescription;
|
||||||
|
|
||||||
|
if (shouldSyncDescription && descriptionDraft.value !== nextAutoDescription) {
|
||||||
|
lastAutoDescriptionRef.current = nextAutoDescription;
|
||||||
|
descriptionDraft.setValue(nextAutoDescription);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- dev defaults applied once on open
|
|
||||||
}, [open]);
|
if (currentDescription === nextAutoDescription) {
|
||||||
|
lastAutoDescriptionRef.current = nextAutoDescription;
|
||||||
|
}
|
||||||
|
}, [descriptionDraft, initialData, isDev, open, suggestedTeamName, teamName]);
|
||||||
|
|
||||||
// Pre-select defaultProjectPath when projects loaded (only while dialog is open)
|
// Pre-select defaultProjectPath when projects loaded (only while dialog is open)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -501,27 +531,19 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
useFileListCacheWarmer(effectiveCwd || null);
|
useFileListCacheWarmer(effectiveCwd || null);
|
||||||
|
|
||||||
|
const { suggestions: taskSuggestions } = useTaskSuggestions(null);
|
||||||
|
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null);
|
||||||
|
|
||||||
const description = descriptionDraft.value;
|
const description = descriptionDraft.value;
|
||||||
const prompt = promptDraft.value;
|
const prompt = promptDraft.value;
|
||||||
|
const memberColorMap = useMemo(() => buildMemberDraftColorMap(members), [members]);
|
||||||
|
|
||||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
const mentionSuggestions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
soloTeam
|
soloTeam
|
||||||
? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }]
|
? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }]
|
||||||
: members
|
: buildMemberDraftSuggestions(members, memberColorMap),
|
||||||
.filter((m) => m.name.trim())
|
[memberColorMap, members, soloTeam]
|
||||||
.map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
name: m.name.trim(),
|
|
||||||
subtitle:
|
|
||||||
m.roleSelection === CUSTOM_ROLE
|
|
||||||
? m.customRole.trim() || undefined
|
|
||||||
: m.roleSelection && m.roleSelection !== NO_ROLE
|
|
||||||
? m.roleSelection
|
|
||||||
: undefined,
|
|
||||||
color: getMemberColorByName(m.name.trim()),
|
|
||||||
})),
|
|
||||||
[members, soloTeam]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const effectiveModel = useMemo(
|
const effectiveModel = useMemo(
|
||||||
|
|
@ -801,7 +823,7 @@ export const CreateTeamDialog = ({
|
||||||
)}
|
)}
|
||||||
value={teamName}
|
value={teamName}
|
||||||
onChange={(event) => handleTeamNameChange(event.target.value)}
|
onChange={(event) => handleTeamNameChange(event.target.value)}
|
||||||
placeholder="team-alpha"
|
placeholder={suggestedTeamName}
|
||||||
/>
|
/>
|
||||||
{existingTeamNames.includes(sanitizedTeamName) ? (
|
{existingTeamNames.includes(sanitizedTeamName) ? (
|
||||||
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
||||||
|
|
@ -833,6 +855,8 @@ export const CreateTeamDialog = ({
|
||||||
showJsonEditor
|
showJsonEditor
|
||||||
draftKeyPrefix="createTeam"
|
draftKeyPrefix="createTeam"
|
||||||
projectPath={effectiveCwd || null}
|
projectPath={effectiveCwd || null}
|
||||||
|
taskSuggestions={taskSuggestions}
|
||||||
|
teamSuggestions={teamMentionSuggestions}
|
||||||
hideContent={soloTeam}
|
hideContent={soloTeam}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -930,6 +954,8 @@ export const CreateTeamDialog = ({
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onValueChange={promptDraft.setValue}
|
onValueChange={promptDraft.setValue}
|
||||||
suggestions={soloTeam ? [] : mentionSuggestions}
|
suggestions={soloTeam ? [] : mentionSuggestions}
|
||||||
|
teamSuggestions={teamMentionSuggestions}
|
||||||
|
taskSuggestions={taskSuggestions}
|
||||||
projectPath={effectiveCwd || null}
|
projectPath={effectiveCwd || null}
|
||||||
chips={promptChipDraft.chips}
|
chips={promptChipDraft.chips}
|
||||||
onChipRemove={promptChipDraft.removeChip}
|
onChipRemove={promptChipDraft.removeChip}
|
||||||
|
|
|
||||||
67
src/renderer/components/team/dialogs/teamNameSets.ts
Normal file
67
src/renderer/components/team/dialogs/teamNameSets.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
const TEAM_NAME_SETS = [
|
||||||
|
['signal-ops', 'forge-labs', 'atlas-hq', 'relay-works', 'beacon-desk', 'vector-room'],
|
||||||
|
['northstar-core', 'summit-ops', 'harbor-labs', 'pilot-desk', 'mission-control', 'launchpad'],
|
||||||
|
['quartz-forge', 'ember-collective', 'prism-works', 'cinder-labs', 'aurora-room', 'sable-ops'],
|
||||||
|
['delta-studio', 'comet-hub', 'orbit-core', 'kernel-crew', 'circuit-labs', 'flux-team'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function normalizeTeamName(name: string): string {
|
||||||
|
return name.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function belongsToBaseTeamName(name: string, baseName: string): boolean {
|
||||||
|
const normalized = normalizeTeamName(name);
|
||||||
|
return normalized === baseName || normalized.startsWith(`${baseName}-`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredTeamNameSet(existingNames: readonly string[]): readonly string[] {
|
||||||
|
for (const nameSet of TEAM_NAME_SETS) {
|
||||||
|
if (
|
||||||
|
nameSet.some((candidate) =>
|
||||||
|
existingNames.some((name) => belongsToBaseTeamName(name, candidate))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return nameSet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TEAM_NAME_SETS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUniqueTeamName(baseName: string, existingNames: readonly string[]): string {
|
||||||
|
const normalizedExisting = new Set(existingNames.map(normalizeTeamName).filter(Boolean));
|
||||||
|
if (!normalizedExisting.has(baseName)) {
|
||||||
|
return baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
let suffix = 2;
|
||||||
|
while (normalizedExisting.has(`${baseName}-${suffix}`)) {
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseName}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextSuggestedTeamName(existingNames: readonly string[]): string {
|
||||||
|
const normalizedExisting = new Set(existingNames.map(normalizeTeamName).filter(Boolean));
|
||||||
|
const preferredSet = getPreferredTeamNameSet(existingNames);
|
||||||
|
|
||||||
|
for (const candidate of preferredSet) {
|
||||||
|
if (!normalizedExisting.has(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nameSet of TEAM_NAME_SETS) {
|
||||||
|
for (const candidate of nameSet) {
|
||||||
|
if (!normalizedExisting.has(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackBaseName = preferredSet[existingNames.length % preferredSet.length] ?? 'signal-ops';
|
||||||
|
return createUniqueTeamName(fallbackBaseName, existingNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TEAM_NAME_SETS };
|
||||||
|
|
@ -20,6 +20,7 @@ import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
interface MemberDraftRowProps {
|
interface MemberDraftRowProps {
|
||||||
member: MemberDraft;
|
member: MemberDraft;
|
||||||
index: number;
|
index: number;
|
||||||
|
resolvedColor?: string;
|
||||||
nameError: string | null;
|
nameError: string | null;
|
||||||
onNameChange: (id: string, name: string) => void;
|
onNameChange: (id: string, name: string) => void;
|
||||||
onRoleChange: (id: string, roleSelection: string) => void;
|
onRoleChange: (id: string, roleSelection: string) => void;
|
||||||
|
|
@ -31,11 +32,14 @@ interface MemberDraftRowProps {
|
||||||
draftKeyPrefix?: string;
|
draftKeyPrefix?: string;
|
||||||
projectPath?: string | null;
|
projectPath?: string | null;
|
||||||
mentionSuggestions?: MentionSuggestion[];
|
mentionSuggestions?: MentionSuggestion[];
|
||||||
|
taskSuggestions?: MentionSuggestion[];
|
||||||
|
teamSuggestions?: MentionSuggestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberDraftRow = ({
|
export const MemberDraftRow = ({
|
||||||
member,
|
member,
|
||||||
index,
|
index,
|
||||||
|
resolvedColor,
|
||||||
nameError,
|
nameError,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
onRoleChange,
|
onRoleChange,
|
||||||
|
|
@ -47,10 +51,12 @@ export const MemberDraftRow = ({
|
||||||
draftKeyPrefix,
|
draftKeyPrefix,
|
||||||
projectPath,
|
projectPath,
|
||||||
mentionSuggestions = [],
|
mentionSuggestions = [],
|
||||||
|
taskSuggestions,
|
||||||
|
teamSuggestions,
|
||||||
}: MemberDraftRowProps): React.JSX.Element => {
|
}: MemberDraftRowProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const memberColorSet = getTeamColorSet(
|
const memberColorSet = getTeamColorSet(
|
||||||
getMemberColorByName(member.name.trim() || `member-${index}`)
|
resolvedColor ?? getMemberColorByName(member.name.trim() || `member-${index}`)
|
||||||
);
|
);
|
||||||
const [workflowExpanded, setWorkflowExpanded] = useState(false);
|
const [workflowExpanded, setWorkflowExpanded] = useState(false);
|
||||||
const [modelExpanded, setModelExpanded] = useState(false);
|
const [modelExpanded, setModelExpanded] = useState(false);
|
||||||
|
|
@ -119,7 +125,7 @@ export const MemberDraftRow = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative grid grid-cols-1 gap-2 overflow-hidden rounded-md p-2 shadow-sm md:grid-cols-[1fr_220px_auto]"
|
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_220px_auto]"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isLight
|
backgroundColor: isLight
|
||||||
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
||||||
|
|
@ -128,7 +134,7 @@ export const MemberDraftRow = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 left-0 w-1"
|
className="absolute inset-y-0 left-0 w-1 rounded-l-md"
|
||||||
style={{ backgroundColor: memberColorSet.border }}
|
style={{ backgroundColor: memberColorSet.border }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
@ -215,6 +221,8 @@ export const MemberDraftRow = ({
|
||||||
value={workflowDraft.value}
|
value={workflowDraft.value}
|
||||||
onValueChange={handleWorkflowChange}
|
onValueChange={handleWorkflowChange}
|
||||||
suggestions={suggestionsExcludingSelf}
|
suggestions={suggestionsExcludingSelf}
|
||||||
|
taskSuggestions={taskSuggestions}
|
||||||
|
teamSuggestions={teamSuggestions}
|
||||||
chips={chips}
|
chips={chips}
|
||||||
onChipRemove={handleChipRemove}
|
onChipRemove={handleChipRemove}
|
||||||
projectPath={projectPath ?? undefined}
|
projectPath={projectPath ?? undefined}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,19 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
||||||
|
|
||||||
import { MemberDraftRow } from './MemberDraftRow';
|
import { MemberDraftRow } from './MemberDraftRow';
|
||||||
import { getNextSuggestedMemberName } from './memberNameSets';
|
import { getNextSuggestedMemberName } from './memberNameSets';
|
||||||
import { createMemberDraft, getWorkflowForExport } from './membersEditorUtils';
|
import {
|
||||||
|
buildMemberDraftColorMap,
|
||||||
|
buildMemberDraftSuggestions,
|
||||||
|
createMemberDraft,
|
||||||
|
getMemberDraftRole,
|
||||||
|
getWorkflowForExport,
|
||||||
|
} from './membersEditorUtils';
|
||||||
|
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
|
|
@ -20,12 +25,7 @@ function membersToJsonText(drafts: MemberDraft[]): string {
|
||||||
const arr = drafts
|
const arr = drafts
|
||||||
.filter((d) => d.name.trim())
|
.filter((d) => d.name.trim())
|
||||||
.map((d) => {
|
.map((d) => {
|
||||||
const role =
|
const role = getMemberDraftRole(d);
|
||||||
d.roleSelection === CUSTOM_ROLE
|
|
||||||
? d.customRole.trim() || undefined
|
|
||||||
: d.roleSelection === NO_ROLE
|
|
||||||
? undefined
|
|
||||||
: d.roleSelection.trim() || undefined;
|
|
||||||
const obj: Record<string, string> = { name: d.name.trim() };
|
const obj: Record<string, string> = { name: d.name.trim() };
|
||||||
if (role) obj.role = role;
|
if (role) obj.role = role;
|
||||||
const workflow = getWorkflowForExport(d);
|
const workflow = getWorkflowForExport(d);
|
||||||
|
|
@ -64,6 +64,10 @@ export interface MembersEditorSectionProps {
|
||||||
draftKeyPrefix?: string;
|
draftKeyPrefix?: string;
|
||||||
/** Project path for @file mentions in workflow */
|
/** Project path for @file mentions in workflow */
|
||||||
projectPath?: string | null;
|
projectPath?: string | null;
|
||||||
|
/** Task suggestions for #task references in workflow */
|
||||||
|
taskSuggestions?: MentionSuggestion[];
|
||||||
|
/** Team suggestions for @@team mentions in workflow */
|
||||||
|
teamSuggestions?: MentionSuggestion[];
|
||||||
/** Extra content rendered right below the "Members" label row */
|
/** Extra content rendered right below the "Members" label row */
|
||||||
headerExtra?: React.ReactNode;
|
headerExtra?: React.ReactNode;
|
||||||
/** When true, hides member rows and action buttons (label + headerExtra still visible) */
|
/** When true, hides member rows and action buttons (label + headerExtra still visible) */
|
||||||
|
|
@ -79,6 +83,8 @@ export const MembersEditorSection = ({
|
||||||
showJsonEditor = true,
|
showJsonEditor = true,
|
||||||
draftKeyPrefix,
|
draftKeyPrefix,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
taskSuggestions,
|
||||||
|
teamSuggestions,
|
||||||
headerExtra,
|
headerExtra,
|
||||||
hideContent = false,
|
hideContent = false,
|
||||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||||
|
|
@ -152,23 +158,11 @@ export const MembersEditorSection = ({
|
||||||
|
|
||||||
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
||||||
const hasDuplicates = new Set(names).size !== names.length;
|
const hasDuplicates = new Set(names).size !== names.length;
|
||||||
|
const memberColorMap = useMemo(() => buildMemberDraftColorMap(members), [members]);
|
||||||
|
|
||||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
const mentionSuggestions = useMemo(
|
||||||
() =>
|
() => buildMemberDraftSuggestions(members, memberColorMap),
|
||||||
members
|
[members, memberColorMap]
|
||||||
.filter((m) => m.name.trim())
|
|
||||||
.map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
name: m.name.trim(),
|
|
||||||
subtitle:
|
|
||||||
m.roleSelection === CUSTOM_ROLE
|
|
||||||
? m.customRole.trim() || undefined
|
|
||||||
: m.roleSelection && m.roleSelection !== NO_ROLE
|
|
||||||
? m.roleSelection
|
|
||||||
: undefined,
|
|
||||||
color: getMemberColorByName(m.name.trim()),
|
|
||||||
})),
|
|
||||||
[members]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -198,6 +192,7 @@ export const MembersEditorSection = ({
|
||||||
key={member.id}
|
key={member.id}
|
||||||
member={member}
|
member={member}
|
||||||
index={index}
|
index={index}
|
||||||
|
resolvedColor={memberColorMap.get(member.name.trim())}
|
||||||
nameError={validateMemberName?.(member.name) ?? null}
|
nameError={validateMemberName?.(member.name) ?? null}
|
||||||
onNameChange={updateMemberName}
|
onNameChange={updateMemberName}
|
||||||
onRoleChange={updateMemberRole}
|
onRoleChange={updateMemberRole}
|
||||||
|
|
@ -209,6 +204,8 @@ export const MembersEditorSection = ({
|
||||||
draftKeyPrefix={draftKeyPrefix}
|
draftKeyPrefix={draftKeyPrefix}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
mentionSuggestions={mentionSuggestions}
|
mentionSuggestions={mentionSuggestions}
|
||||||
|
taskSuggestions={taskSuggestions}
|
||||||
|
teamSuggestions={teamSuggestions}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{jsonEditorOpen && showJsonEditor ? (
|
{jsonEditorOpen && showJsonEditor ? (
|
||||||
|
|
@ -237,7 +234,10 @@ export const MembersEditorSection = ({
|
||||||
|
|
||||||
export type { MemberDraft } from './membersEditorTypes';
|
export type { MemberDraft } from './membersEditorTypes';
|
||||||
export {
|
export {
|
||||||
|
buildMemberDraftColorMap,
|
||||||
|
buildMemberDraftSuggestions,
|
||||||
buildMembersFromDrafts,
|
buildMembersFromDrafts,
|
||||||
createMemberDraft,
|
createMemberDraft,
|
||||||
|
getMemberDraftRole,
|
||||||
validateMemberNameInline,
|
validateMemberNameInline,
|
||||||
} from './membersEditorUtils';
|
} from './membersEditorUtils';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
|
import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
|
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { TeamProvisioningMemberInput } from '@shared/types';
|
import type { TeamProvisioningMemberInput } from '@shared/types';
|
||||||
|
|
||||||
function isValidMemberName(name: string): boolean {
|
function isValidMemberName(name: string): boolean {
|
||||||
|
|
@ -34,6 +36,41 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildMemberDraftColorMap(
|
||||||
|
members: ReadonlyArray<Pick<MemberDraft, 'name'>>
|
||||||
|
): Map<string, string> {
|
||||||
|
return buildMemberColorMap(
|
||||||
|
members
|
||||||
|
.map((member) => member.name.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((name) => ({ name }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a MemberDraft's role selection to a display string. */
|
||||||
|
export function getMemberDraftRole(member: MemberDraft): string | undefined {
|
||||||
|
return member.roleSelection === CUSTOM_ROLE
|
||||||
|
? member.customRole.trim() || undefined
|
||||||
|
: member.roleSelection === NO_ROLE
|
||||||
|
? undefined
|
||||||
|
: member.roleSelection.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds MentionSuggestion[] from MemberDraft[], reusing color map and role resolution. */
|
||||||
|
export function buildMemberDraftSuggestions(
|
||||||
|
members: MemberDraft[],
|
||||||
|
colorMap: Map<string, string>
|
||||||
|
): MentionSuggestion[] {
|
||||||
|
return members
|
||||||
|
.filter((m) => m.name.trim())
|
||||||
|
.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name.trim(),
|
||||||
|
subtitle: getMemberDraftRole(m),
|
||||||
|
color: colorMap.get(m.name.trim()) ?? undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolves workflow for export (JSON or API): serializes chips when present. */
|
/** Resolves workflow for export (JSON or API): serializes chips when present. */
|
||||||
export function getWorkflowForExport(member: MemberDraft): string | undefined {
|
export function getWorkflowForExport(member: MemberDraft): string | undefined {
|
||||||
const workflowRaw = member.workflow?.trim();
|
const workflowRaw = member.workflow?.trim();
|
||||||
|
|
@ -50,13 +87,7 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const role =
|
const role = getMemberDraftRole(member);
|
||||||
member.roleSelection === CUSTOM_ROLE
|
|
||||||
? member.customRole.trim() || undefined
|
|
||||||
: member.roleSelection === NO_ROLE
|
|
||||||
? undefined
|
|
||||||
: member.roleSelection.trim() || undefined;
|
|
||||||
|
|
||||||
const result: TeamProvisioningMemberInput = { name, role };
|
const result: TeamProvisioningMemberInput = { name, role };
|
||||||
const workflow = getWorkflowForExport(member);
|
const workflow = getWorkflowForExport(member);
|
||||||
if (workflow) result.workflow = workflow;
|
if (workflow) result.workflow = workflow;
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,9 @@ export const MentionSuggestionList = ({
|
||||||
<UsersRound
|
<UsersRound
|
||||||
size={13}
|
size={13}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
style={{ color: colorSet?.text ?? 'var(--color-text-muted)' }}
|
style={{
|
||||||
|
color: colorSet ? getThemedText(colorSet, isLight) : 'var(--color-text-muted)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
|
|
@ -180,7 +182,7 @@ export const MentionSuggestionList = ({
|
||||||
isTask
|
isTask
|
||||||
? { color: 'var(--color-link, #60a5fa)' }
|
? { color: 'var(--color-link, #60a5fa)' }
|
||||||
: colorSet
|
: colorSet
|
||||||
? { color: colorSet.text }
|
? { color: getThemedText(colorSet, isLight) }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ interface TaskSegment {
|
||||||
value: string;
|
value: string;
|
||||||
suggestion: MentionSuggestion;
|
suggestion: MentionSuggestion;
|
||||||
encoded: boolean;
|
encoded: boolean;
|
||||||
|
/** Zero-width metadata chars rendered in backdrop for caret alignment */
|
||||||
|
hiddenSuffix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UrlSegment {
|
interface UrlSegment {
|
||||||
|
|
@ -197,11 +199,16 @@ function parseSuggestionSegments(
|
||||||
if (match.start > lastEnd) {
|
if (match.start > lastEnd) {
|
||||||
segments.push(...parseMentionSegments(text.slice(lastEnd, match.start), mentionSuggestions));
|
segments.push(...parseMentionSegments(text.slice(lastEnd, match.start), mentionSuggestions));
|
||||||
}
|
}
|
||||||
|
// Compute hidden suffix: zero-width metadata chars between visible text and match.end
|
||||||
|
const visibleEnd = match.start + match.raw.length;
|
||||||
|
const hiddenSuffix =
|
||||||
|
match.encoded && match.end > visibleEnd ? text.slice(visibleEnd, match.end) : undefined;
|
||||||
segments.push({
|
segments.push({
|
||||||
type: 'task',
|
type: 'task',
|
||||||
value: match.raw,
|
value: match.raw,
|
||||||
suggestion: match.suggestion,
|
suggestion: match.suggestion,
|
||||||
encoded: match.encoded,
|
encoded: match.encoded,
|
||||||
|
hiddenSuffix,
|
||||||
});
|
});
|
||||||
lastEnd = match.end;
|
lastEnd = match.end;
|
||||||
}
|
}
|
||||||
|
|
@ -987,25 +994,26 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
}
|
}
|
||||||
if (seg.type === 'task') {
|
if (seg.type === 'task') {
|
||||||
return (
|
return (
|
||||||
<span
|
<React.Fragment key={idx}>
|
||||||
key={idx}
|
<span
|
||||||
className={
|
className={seg.encoded ? 'rounded' : 'underline decoration-transparent'}
|
||||||
seg.encoded
|
style={
|
||||||
? 'rounded px-1.5 py-0.5 font-medium'
|
seg.encoded
|
||||||
: 'font-medium underline decoration-transparent'
|
? {
|
||||||
}
|
backgroundColor: 'rgba(59, 130, 246, 0.15)',
|
||||||
style={
|
color: PROSE_LINK,
|
||||||
seg.encoded
|
// Only vertical padding (doesn't affect inline text flow).
|
||||||
? {
|
// No horizontal padding/margin/box-shadow spread to avoid
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.15)',
|
// caret drift or visual overlap with adjacent text.
|
||||||
color: PROSE_LINK,
|
padding: '2px 0',
|
||||||
boxShadow: '0 0 0 1.5px rgba(59, 130, 246, 0.15)',
|
}
|
||||||
}
|
: { color: PROSE_LINK }
|
||||||
: { color: PROSE_LINK }
|
}
|
||||||
}
|
>
|
||||||
>
|
{seg.value}
|
||||||
{seg.value}
|
</span>
|
||||||
</span>
|
{seg.hiddenSuffix}
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (seg.type === 'url') {
|
if (seg.type === 'url') {
|
||||||
|
|
@ -1121,16 +1129,16 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showFooter ? (
|
{showFooter ? (
|
||||||
<div className="mt-1 flex items-center justify-between">
|
<div className="mt-1 flex items-start justify-between gap-2">
|
||||||
{showHintRow ? (
|
{showHintRow ? (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] text-[var(--color-text-muted)] transition-opacity duration-300"
|
className="block min-h-6 flex-1 overflow-hidden text-[10px] leading-3 text-[var(--color-text-muted)] transition-opacity duration-300"
|
||||||
style={{ opacity: tipVisible ? 1 : 0 }}
|
style={{ opacity: tipVisible ? 1 : 0, maxHeight: '1.5rem' }}
|
||||||
>
|
>
|
||||||
{resolvedHintText}
|
{resolvedHintText}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span className="min-h-6 flex-1" />
|
||||||
)}
|
)}
|
||||||
{footerRight}
|
{footerRight}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
* Used by TeammateMessageItem and SubagentItem when displaying team members.
|
* Used by TeammateMessageItem and SubagentItem when displaying team members.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors';
|
||||||
|
|
||||||
export interface TeamColorSet {
|
export interface TeamColorSet {
|
||||||
/** Border accent color */
|
/** Border accent color */
|
||||||
border: string;
|
border: string;
|
||||||
|
|
@ -128,6 +130,33 @@ export function getSubagentTypeColorSet(
|
||||||
/** Assignable visual colors (excludes reserved 'user'). */
|
/** Assignable visual colors (excludes reserved 'user'). */
|
||||||
const ASSIGNABLE_COLORS = COLOR_NAMES.filter((c) => c !== 'user');
|
const ASSIGNABLE_COLORS = COLOR_NAMES.filter((c) => c !== 'user');
|
||||||
|
|
||||||
|
function hsla(hue: number, saturation: number, lightness: number, alpha = 1): string {
|
||||||
|
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGeneratedMemberColorSet(colorName: string): TeamColorSet | null {
|
||||||
|
const paletteIndex = MEMBER_COLOR_PALETTE.indexOf(
|
||||||
|
colorName as (typeof MEMBER_COLOR_PALETTE)[number]
|
||||||
|
);
|
||||||
|
if (paletteIndex === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spread the extended member palette across the hue wheel so distinct palette
|
||||||
|
// names stay visually distinct instead of collapsing back into 8 base colors.
|
||||||
|
const hue = Math.round((paletteIndex / MEMBER_COLOR_PALETTE.length) * 360);
|
||||||
|
const saturation = 72;
|
||||||
|
|
||||||
|
return {
|
||||||
|
border: hsla(hue, saturation, 50),
|
||||||
|
borderLight: hsla(hue, saturation, 44),
|
||||||
|
badge: hsla(hue, saturation, 50, 0.15),
|
||||||
|
badgeLight: hsla(hue, saturation, 50, 0.12),
|
||||||
|
text: hsla(hue, 78, 66),
|
||||||
|
textLight: hsla(hue, 82, 36),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getTeamColorSet(colorName: string): TeamColorSet {
|
export function getTeamColorSet(colorName: string): TeamColorSet {
|
||||||
if (!colorName) return DEFAULT_COLOR;
|
if (!colorName) return DEFAULT_COLOR;
|
||||||
|
|
||||||
|
|
@ -135,6 +164,9 @@ export function getTeamColorSet(colorName: string): TeamColorSet {
|
||||||
const named = TEAMMATE_COLORS[colorName.toLowerCase()];
|
const named = TEAMMATE_COLORS[colorName.toLowerCase()];
|
||||||
if (named) return named;
|
if (named) return named;
|
||||||
|
|
||||||
|
const generatedMemberColor = buildGeneratedMemberColorSet(colorName.toLowerCase());
|
||||||
|
if (generatedMemberColor) return generatedMemberColor;
|
||||||
|
|
||||||
// If it's a hex color, generate a set from it
|
// If it's a hex color, generate a set from it
|
||||||
if (colorName.startsWith('#')) {
|
if (colorName.startsWith('#')) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue