mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 03:24:59 +02:00
feat(core): Add agent favorites (#33899)
This commit is contained in:
parent
d06c2b7f09
commit
ff5d4f1d3e
|
|
@ -1,2 +1,8 @@
|
|||
export type FavoriteResourceType = 'workflow' | 'project' | 'dataTable' | 'folder';
|
||||
export const FAVORITE_RESOURCE_TYPES = ['workflow', 'project', 'dataTable', 'folder'] as const;
|
||||
export type FavoriteResourceType = 'workflow' | 'project' | 'dataTable' | 'folder' | 'agent';
|
||||
export const FAVORITE_RESOURCE_TYPES = [
|
||||
'workflow',
|
||||
'project',
|
||||
'dataTable',
|
||||
'folder',
|
||||
'agent',
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -1035,6 +1035,12 @@ export type RelayEventMap = {
|
|||
|
||||
// #endregion
|
||||
|
||||
// region Agents
|
||||
'agent-deleted': {
|
||||
agentId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
// #region Instance Policies
|
||||
|
||||
'instance-policies-updated': { user: UserLike } & (
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { mock } from 'vitest-mock-extended';
|
|||
import type { ActiveExecutions } from '@/active-executions';
|
||||
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import type { EphemeralNodeExecutor } from '@/node-execution';
|
||||
import type { OauthService } from '@/oauth/oauth.service';
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
|
|
@ -287,6 +288,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
|
|||
agentTestChatService,
|
||||
agentTaskRepository,
|
||||
mock<SubAgentCleanupService>(),
|
||||
mock<EventService>(),
|
||||
);
|
||||
service = agentExecutionOrchestratorService;
|
||||
markSharedTestSetupAsUsed(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { ChatIntegrationService } from '../integrations/chat-integration.service
|
|||
import type { AgentTaskRepository } from '../repositories/agent-task.repository';
|
||||
import type { AgentRepository } from '../repositories/agent.repository';
|
||||
import type { SubAgentCleanupService } from '../sub-agents/sub-agent-cleanup.service';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
|
||||
const agentId = 'agent-1';
|
||||
const projectId = 'project-1';
|
||||
|
|
@ -48,6 +49,7 @@ function makeService() {
|
|||
const agentTaskRepository = mock<AgentTaskRepository>();
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
const subAgentCleanupService = mock<SubAgentCleanupService>();
|
||||
const eventService = mock<EventService>();
|
||||
|
||||
agentRepository.save.mockImplementation(async (agent) => agent as Agent);
|
||||
agentTaskService.requestReconcile.mockResolvedValue();
|
||||
|
|
@ -66,6 +68,7 @@ function makeService() {
|
|||
testChatService,
|
||||
agentTaskRepository,
|
||||
subAgentCleanupService,
|
||||
eventService,
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -78,6 +81,7 @@ function makeService() {
|
|||
agentTaskRepository,
|
||||
chatIntegrationService,
|
||||
subAgentCleanupService,
|
||||
eventService,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +127,7 @@ describe('AgentsService', () => {
|
|||
agentTaskService,
|
||||
chatIntegrationService,
|
||||
subAgentCleanupService,
|
||||
eventService,
|
||||
} = makeService();
|
||||
const agent = makeAgent({
|
||||
integrations: [
|
||||
|
|
@ -156,6 +161,27 @@ describe('AgentsService', () => {
|
|||
expect(agentTaskService.requestReconcile).toHaveBeenCalledWith(agentId);
|
||||
expect(testChatService.clearAllTestChatMessages).toHaveBeenCalledWith(agentId);
|
||||
expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(projectId, agentId);
|
||||
expect(eventService.emit).toHaveBeenCalledWith('agent-deleted', { agentId, projectId });
|
||||
});
|
||||
|
||||
it('emits agent-deleted only after the agent row is removed', async () => {
|
||||
const { service, agentRepository, eventService } = makeService();
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent());
|
||||
|
||||
await service.delete(agentId, projectId);
|
||||
|
||||
expect(eventService.emit).toHaveBeenCalledWith('agent-deleted', { agentId, projectId });
|
||||
expect(eventService.emit.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
agentRepository.remove.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('does not emit agent-deleted when the agent is missing', async () => {
|
||||
const { service, agentRepository, eventService } = makeService();
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete(agentId, projectId)).resolves.toBe(false);
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still deletes the agent when best-effort cleanup fails', async () => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { ChatIntegrationService } from './integrations/chat-integration.service'
|
|||
import { AgentTaskRepository } from './repositories/agent-task.repository';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import { SubAgentCleanupService } from './sub-agents/sub-agent-cleanup.service';
|
||||
import { EventService } from '@/events/event.service';
|
||||
|
||||
@Service()
|
||||
export class AgentsService {
|
||||
|
|
@ -32,6 +33,7 @@ export class AgentsService {
|
|||
private readonly testChatService: AgentTestChatService,
|
||||
private readonly agentTaskRepository: AgentTaskRepository,
|
||||
private readonly subAgentCleanupService: SubAgentCleanupService,
|
||||
private readonly eventService: EventService,
|
||||
) {}
|
||||
|
||||
async create(projectId: string, name: string): Promise<Agent> {
|
||||
|
|
@ -211,6 +213,8 @@ export class AgentsService {
|
|||
|
||||
await this.subAgentCleanupService.removeSubAgentFromParents(agentId, projectId);
|
||||
|
||||
this.eventService.emit('agent-deleted', { agentId, projectId });
|
||||
|
||||
try {
|
||||
const { AgentTaskService } = await import('./agent-task.service');
|
||||
await Container.get(AgentTaskService).requestReconcile(agentId);
|
||||
|
|
|
|||
|
|
@ -76,4 +76,19 @@ describe('FavoritesEventRelay', () => {
|
|||
expect(favoritesService.deleteByResource).toHaveBeenCalledWith('proj1', 'project');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent-deleted', () => {
|
||||
it('should delete favorites for the deleted agent', async () => {
|
||||
const event: RelayEventMap['agent-deleted'] = {
|
||||
agentId: 'agent1',
|
||||
projectId: 'proj1',
|
||||
};
|
||||
|
||||
eventService.emit('agent-deleted', event);
|
||||
|
||||
await new Promise(setImmediate);
|
||||
|
||||
expect(favoritesService.deleteByResource).toHaveBeenCalledWith('agent1', 'agent');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { mock } from 'vitest-mock-extended';
|
|||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { DataTableRepository } from '@/modules/data-table/data-table.repository';
|
||||
import type { AgentRepository } from '@/modules/agents/repositories/agent.repository';
|
||||
|
||||
import type { UserFavorite } from '../database/entities/user-favorite.entity';
|
||||
import type { UserFavoriteRepository } from '../database/repositories/user-favorite.repository';
|
||||
|
|
@ -47,6 +48,7 @@ describe('FavoritesService', () => {
|
|||
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
|
||||
const dataTableRepository = mock<DataTableRepository>();
|
||||
const folderRepository = mock<FolderRepository>();
|
||||
const agentRepository = mock<AgentRepository>();
|
||||
const service = new FavoritesService(
|
||||
repo,
|
||||
workflowRepository,
|
||||
|
|
@ -54,6 +56,7 @@ describe('FavoritesService', () => {
|
|||
sharedWorkflowRepository,
|
||||
dataTableRepository,
|
||||
folderRepository,
|
||||
agentRepository,
|
||||
);
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
|
@ -231,12 +234,48 @@ describe('FavoritesService', () => {
|
|||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should enrich agent favorites with name and projectId for accessible agents', async () => {
|
||||
const favorite = makeUserFavorite({ resourceId: 'agent1', resourceType: 'agent' });
|
||||
repo.findByUser.mockResolvedValue([favorite]);
|
||||
projectRepository.getAccessibleProjects.mockResolvedValue([
|
||||
{ id: 'proj1', name: 'Project 1' } as never,
|
||||
]);
|
||||
agentRepository.find.mockResolvedValue([
|
||||
{ id: 'agent1', name: 'My Agent', projectId: 'proj1' } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
resourceId: 'agent1',
|
||||
resourceName: 'My Agent',
|
||||
resourceProjectId: 'proj1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should exclude agent favorites for inaccessible projects', async () => {
|
||||
const favorite = makeUserFavorite({ resourceId: 'agent1', resourceType: 'agent' });
|
||||
repo.findByUser.mockResolvedValue([favorite]);
|
||||
projectRepository.getAccessibleProjects.mockResolvedValue([
|
||||
{ id: 'proj1', name: 'Project 1' } as never,
|
||||
]);
|
||||
agentRepository.find.mockResolvedValue([
|
||||
{ id: 'agent1', name: 'My Agent', projectId: 'proj-other' } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle multiple resource types in one call', async () => {
|
||||
repo.findByUser.mockResolvedValue([
|
||||
makeUserFavorite({ id: 1, resourceId: 'wf1', resourceType: 'workflow' }),
|
||||
makeUserFavorite({ id: 2, resourceId: 'proj1', resourceType: 'project' }),
|
||||
makeUserFavorite({ id: 3, resourceId: 'dt1', resourceType: 'dataTable' }),
|
||||
makeUserFavorite({ id: 4, resourceId: 'folder1', resourceType: 'folder' }),
|
||||
makeUserFavorite({ id: 5, resourceId: 'agent1', resourceType: 'agent' }),
|
||||
]);
|
||||
projectRepository.getAccessibleProjects.mockResolvedValue([
|
||||
{ id: 'proj1', name: 'My Project' } as never,
|
||||
|
|
@ -249,10 +288,13 @@ describe('FavoritesService', () => {
|
|||
folderRepository.find.mockResolvedValue([
|
||||
{ id: 'folder1', name: 'My Folder', homeProject: { id: 'proj1' } } as never,
|
||||
]);
|
||||
agentRepository.find.mockResolvedValue([
|
||||
{ id: 'agent1', name: 'My Agent', projectId: 'proj1' } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
|
||||
describe('global admin access', () => {
|
||||
|
|
@ -328,6 +370,24 @@ describe('FavoritesService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should enrich agent favorites without explicit project membership', async () => {
|
||||
const favorite = makeUserFavorite({ resourceId: 'agent1', resourceType: 'agent' });
|
||||
repo.findByUser.mockResolvedValue([favorite]);
|
||||
projectRepository.getAccessibleProjects.mockResolvedValue([]);
|
||||
agentRepository.find.mockResolvedValue([
|
||||
{ id: 'agent1', name: 'My Agent', projectId: 'proj-other' } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites(makeAdmin());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
resourceId: 'agent1',
|
||||
resourceName: 'My Agent',
|
||||
resourceProjectId: 'proj-other',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not query for additional projects when admin already has membership', async () => {
|
||||
const favorite = makeUserFavorite({ resourceId: 'proj1', resourceType: 'project' });
|
||||
repo.findByUser.mockResolvedValue([favorite]);
|
||||
|
|
@ -417,6 +477,7 @@ describe('FavoritesService', () => {
|
|||
{ resourceType: 'project' as const, repository: 'projectRepository' },
|
||||
{ resourceType: 'dataTable' as const, repository: 'dataTableRepository' },
|
||||
{ resourceType: 'folder' as const, repository: 'folderRepository' },
|
||||
{ resourceType: 'agent' as const, repository: 'agentRepository' },
|
||||
])(
|
||||
'should check existence using the correct repository for $resourceType',
|
||||
async ({ resourceType, repository }) => {
|
||||
|
|
@ -425,6 +486,7 @@ describe('FavoritesService', () => {
|
|||
projectRepository,
|
||||
dataTableRepository,
|
||||
folderRepository,
|
||||
agentRepository,
|
||||
};
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
repositories[repository as keyof typeof repositories].existsBy.mockResolvedValue(false);
|
||||
|
|
|
|||
|
|
@ -22,9 +22,10 @@ export class FavoritesEventRelay extends EventRelay {
|
|||
await this.favoritesService.deleteByResource(dataTableId, 'dataTable'),
|
||||
'folder-deleted': async ({ folderId }) =>
|
||||
await this.favoritesService.deleteByResource(folderId, 'folder'),
|
||||
'team-project-deleted': async ({ projectId }) => {
|
||||
await this.favoritesService.deleteByResource(projectId, 'project');
|
||||
},
|
||||
'team-project-deleted': async ({ projectId }) =>
|
||||
await this.favoritesService.deleteByResource(projectId, 'project'),
|
||||
'agent-deleted': async ({ agentId }) =>
|
||||
await this.favoritesService.deleteByResource(agentId, 'agent'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { DataTableRepository } from '@/modules/data-table/data-table.repository'
|
|||
|
||||
import { UserFavoriteRepository } from './database/repositories/user-favorite.repository';
|
||||
|
||||
import { AgentRepository } from '../agents/repositories/agent.repository';
|
||||
|
||||
type ResourceMeta = { name: string; projectId: string };
|
||||
|
||||
type Favorite = { resourceId: string; resourceType: FavoriteResourceType };
|
||||
|
|
@ -48,6 +50,7 @@ export class FavoritesService {
|
|||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||
private readonly dataTableRepository: DataTableRepository,
|
||||
private readonly folderRepository: FolderRepository,
|
||||
private readonly agentRepository: AgentRepository,
|
||||
) {}
|
||||
|
||||
async getEnrichedFavorites(user: User) {
|
||||
|
|
@ -59,16 +62,18 @@ export class FavoritesService {
|
|||
project: idsOfType(favorites, 'project'),
|
||||
dataTable: idsOfType(favorites, 'dataTable'),
|
||||
folder: idsOfType(favorites, 'folder'),
|
||||
agent: idsOfType(favorites, 'agent'),
|
||||
};
|
||||
|
||||
const accessibleProjects = await this.projectRepository.getAccessibleProjects(user.id);
|
||||
const accessibleProjectIds = new Set(accessibleProjects.map((p) => p.id));
|
||||
|
||||
const [workflowNames, projectNames, dataTableMeta, folderMeta] = await Promise.all([
|
||||
const [workflowNames, projectNames, dataTableMeta, folderMeta, agentMeta] = await Promise.all([
|
||||
this.enrichWorkflowFavorites(user, ids.workflow, accessibleProjectIds),
|
||||
this.enrichProjectFavorites(user, ids.project, accessibleProjects),
|
||||
this.enrichDataTableFavorites(user, ids.dataTable, accessibleProjectIds),
|
||||
this.enrichFolderFavorites(user, ids.folder, accessibleProjectIds),
|
||||
this.enrichAgentFavorites(user, ids.agent, accessibleProjectIds),
|
||||
]);
|
||||
|
||||
return favorites.flatMap((fav) => {
|
||||
|
|
@ -81,6 +86,8 @@ export class FavoritesService {
|
|||
return enrichWithMeta(fav, dataTableMeta.get(fav.resourceId));
|
||||
case 'folder':
|
||||
return enrichWithMeta(fav, folderMeta.get(fav.resourceId));
|
||||
case 'agent':
|
||||
return enrichWithMeta(fav, agentMeta.get(fav.resourceId));
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
|
@ -183,6 +190,27 @@ export class FavoritesService {
|
|||
return result;
|
||||
}
|
||||
|
||||
private async enrichAgentFavorites(
|
||||
user: User,
|
||||
agentIds: string[],
|
||||
accessibleProjectIds: Set<string>,
|
||||
): Promise<Map<string, ResourceMeta>> {
|
||||
const result = new Map<string, ResourceMeta>();
|
||||
if (agentIds.length === 0) return result;
|
||||
|
||||
const hasGlobalAccess = hasGlobalScope(user, 'agent:read');
|
||||
const agents = await this.agentRepository.find({
|
||||
where: { id: In(agentIds) },
|
||||
});
|
||||
|
||||
for (const agent of agents) {
|
||||
if (hasGlobalAccess || accessibleProjectIds.has(agent.projectId)) {
|
||||
result.set(agent.id, { name: agent.name, projectId: agent.projectId });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async addFavorite(userId: string, resourceId: string, resourceType: FavoriteResourceType) {
|
||||
const existing = await this.userFavoriteRepository.findOne({
|
||||
where: { userId, resourceId, resourceType },
|
||||
|
|
@ -220,6 +248,9 @@ export class FavoritesService {
|
|||
case 'folder':
|
||||
exists = await this.folderRepository.existsBy({ id: resourceId });
|
||||
break;
|
||||
case 'agent':
|
||||
exists = await this.agentRepository.existsBy({ id: resourceId });
|
||||
break;
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ const getItemClasses = (item: ActionDropdownItem<T>): Record<string, boolean> =>
|
|||
font-size: var(--font-size--2xs);
|
||||
line-height: 18px;
|
||||
padding: var(--spacing--3xs) var(--spacing--2xs);
|
||||
|
||||
min-width: fit-content;
|
||||
&.disabled {
|
||||
.shortcut {
|
||||
opacity: 0.3;
|
||||
|
|
|
|||
|
|
@ -195,6 +195,52 @@ describe('favorites.store', () => {
|
|||
expect(store.favorites).toHaveLength(1);
|
||||
expect(track).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add an agent favorite and re-fetch', async () => {
|
||||
const newFavorite = makeFavorite({
|
||||
id: 3,
|
||||
resourceId: 'agent-1',
|
||||
resourceType: 'agent',
|
||||
resourceName: 'My Agent',
|
||||
});
|
||||
vi.mocked(favoritesApi.getFavorites)
|
||||
.mockResolvedValueOnce([]) // initial fetch
|
||||
.mockResolvedValueOnce([newFavorite]); // re-fetch after add
|
||||
vi.mocked(favoritesApi.addFavorite).mockResolvedValue(newFavorite);
|
||||
|
||||
const store = useFavoritesStore();
|
||||
await store.fetchFavorites();
|
||||
await store.toggleFavorite('agent-1', 'agent');
|
||||
|
||||
expect(favoritesApi.addFavorite).toHaveBeenCalledWith(expect.anything(), 'agent-1', 'agent');
|
||||
expect(store.favorites).toEqual([newFavorite]);
|
||||
expect(track).toHaveBeenCalledWith('User toggled favorite', {
|
||||
action: 'added',
|
||||
resource_type: 'agent',
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove an agent favorite', async () => {
|
||||
vi.mocked(favoritesApi.getFavorites).mockResolvedValue([
|
||||
makeFavorite({ id: 3, resourceId: 'agent-1', resourceType: 'agent' }),
|
||||
]);
|
||||
vi.mocked(favoritesApi.removeFavorite).mockResolvedValue(true);
|
||||
|
||||
const store = useFavoritesStore();
|
||||
await store.fetchFavorites();
|
||||
await store.toggleFavorite('agent-1', 'agent');
|
||||
|
||||
expect(favoritesApi.removeFavorite).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'agent-1',
|
||||
'agent',
|
||||
);
|
||||
expect(store.favorites).toHaveLength(0);
|
||||
expect(track).toHaveBeenCalledWith('User toggled favorite', {
|
||||
action: 'removed',
|
||||
resource_type: 'agent',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFavoriteLocally()', () => {
|
||||
|
|
@ -293,6 +339,18 @@ describe('favorites.store', () => {
|
|||
expect(store.folderFavoriteIds).toEqual(['folder-1']);
|
||||
});
|
||||
|
||||
it('should return agent favorite IDs', async () => {
|
||||
vi.mocked(favoritesApi.getFavorites).mockResolvedValue([
|
||||
makeFavorite({ resourceId: 'wf-1', resourceType: 'workflow' }),
|
||||
makeFavorite({ id: 2, resourceId: 'agent-1', resourceType: 'agent' }),
|
||||
]);
|
||||
|
||||
const store = useFavoritesStore();
|
||||
await store.fetchFavorites();
|
||||
|
||||
expect(store.agentFavoriteIds).toEqual(['agent-1']);
|
||||
});
|
||||
|
||||
it('should return empty arrays when no favorites exist', () => {
|
||||
const store = useFavoritesStore();
|
||||
|
||||
|
|
@ -300,6 +358,7 @@ describe('favorites.store', () => {
|
|||
expect(store.projectFavoriteIds).toEqual([]);
|
||||
expect(store.dataTableFavoriteIds).toEqual([]);
|
||||
expect(store.folderFavoriteIds).toEqual([]);
|
||||
expect(store.agentFavoriteIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ export const useFavoritesStore = defineStore(STORES.FAVORITES, () => {
|
|||
favorites.value.filter((f) => f.resourceType === 'folder').map((f) => f.resourceId),
|
||||
);
|
||||
|
||||
const agentFavoriteIds = computed(() =>
|
||||
favorites.value.filter((f) => f.resourceType === 'agent').map((f) => f.resourceId),
|
||||
);
|
||||
|
||||
async function fetchFavorites() {
|
||||
if (initialized.value) return;
|
||||
const currentPushRef = rootStore.restApiContext.pushRef;
|
||||
|
|
@ -96,6 +100,7 @@ export const useFavoritesStore = defineStore(STORES.FAVORITES, () => {
|
|||
projectFavoriteIds,
|
||||
dataTableFavoriteIds,
|
||||
folderFavoriteIds,
|
||||
agentFavoriteIds,
|
||||
fetchFavorites,
|
||||
isFavorite,
|
||||
renameFavorite,
|
||||
|
|
|
|||
|
|
@ -159,6 +159,17 @@ vi.mock('../composables/useAgentPermissions', () => ({
|
|||
useAgentPermissions: () => agentPermissionsMock,
|
||||
}));
|
||||
|
||||
const favoritesStoreMock = vi.hoisted(() => ({
|
||||
isFavorite: vi.fn(() => false),
|
||||
toggleFavorite: vi.fn().mockResolvedValue(undefined),
|
||||
renameFavorite: vi.fn(),
|
||||
removeFavoriteLocally: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/favorites.store', () => ({
|
||||
useFavoritesStore: () => favoritesStoreMock,
|
||||
}));
|
||||
|
||||
// Real ref so the view's `watch(config, ...)` fires and populates `localConfig`.
|
||||
// Tests that need an unbuilt agent flip this to empty instructions before render.
|
||||
interface TestAgentConfig {
|
||||
|
|
@ -535,6 +546,10 @@ describe('AgentBuilderView — preview routing', () => {
|
|||
showErrorMock.mockReset();
|
||||
fetchConfigMock.mockClear();
|
||||
showErrorMock.mockReset();
|
||||
favoritesStoreMock.isFavorite.mockReturnValue(false);
|
||||
favoritesStoreMock.toggleFavorite.mockClear();
|
||||
favoritesStoreMock.renameFavorite.mockClear();
|
||||
favoritesStoreMock.removeFavoriteLocally.mockClear();
|
||||
});
|
||||
|
||||
it('renders the build chat in the editing experience without the old mode toggle', async () => {
|
||||
|
|
@ -896,6 +911,10 @@ describe('AgentBuilderView — three-column shell', () => {
|
|||
uploadAgentFilesMock.mockResolvedValue([]);
|
||||
showErrorMock.mockReset();
|
||||
fetchConfigMock.mockClear();
|
||||
favoritesStoreMock.isFavorite.mockReturnValue(false);
|
||||
favoritesStoreMock.toggleFavorite.mockClear();
|
||||
favoritesStoreMock.renameFavorite.mockClear();
|
||||
favoritesStoreMock.removeFavoriteLocally.mockClear();
|
||||
});
|
||||
|
||||
it('hides the build chat by default while keeping the editor visible', async () => {
|
||||
|
|
@ -1217,6 +1236,27 @@ describe('AgentBuilderView — three-column shell', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('toggles the favorite from the header menu', async () => {
|
||||
const wrapper = await renderView();
|
||||
|
||||
wrapper
|
||||
.findComponent({ name: 'AgentBuilderHeader' })
|
||||
.vm.$emit('header-action', 'toggleFavorite');
|
||||
await flushPromises();
|
||||
|
||||
expect(favoritesStoreMock.toggleFavorite).toHaveBeenCalledWith('a1', 'agent');
|
||||
});
|
||||
|
||||
it('updates the favorite name in the sidebar when the agent is renamed', async () => {
|
||||
const wrapper = await renderView();
|
||||
|
||||
wrapper
|
||||
.findComponent({ name: 'AgentBuilderEditorColumn' })
|
||||
.vm.$emit('update:config', { name: 'Renamed Agent' });
|
||||
|
||||
expect(favoritesStoreMock.renameFavorite).toHaveBeenCalledWith('a1', 'agent', 'Renamed Agent');
|
||||
});
|
||||
|
||||
it('exports the current agent config as a JSON file from the header menu', async () => {
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
|
|
@ -1284,6 +1324,11 @@ describe('AgentBuilderView — three-column shell', () => {
|
|||
expect((wrapper.vm as unknown as { agent: { name: string } }).agent.name).toBe(
|
||||
'Imported agent',
|
||||
);
|
||||
expect(favoritesStoreMock.renameFavorite).toHaveBeenLastCalledWith(
|
||||
'a1',
|
||||
'agent',
|
||||
'Imported agent',
|
||||
);
|
||||
|
||||
await (wrapper.vm as unknown as { flushAutosave: () => Promise<void> }).flushAutosave();
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,17 @@ import { ref } from 'vue';
|
|||
import type { AgentResource } from '../types';
|
||||
import type { AgentVersion } from '../agent.types';
|
||||
|
||||
const { deleteAgentMock, openAgentConfirmationModalMock } = vi.hoisted(() => ({
|
||||
deleteAgentMock: vi.fn().mockResolvedValue(undefined),
|
||||
openAgentConfirmationModalMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../composables/useAgentApi', () => ({
|
||||
deleteAgent: vi.fn(),
|
||||
deleteAgent: deleteAgentMock,
|
||||
}));
|
||||
|
||||
vi.mock('../composables/useAgentConfirmationModal', () => ({
|
||||
useAgentConfirmationModal: () => ({ openAgentConfirmationModal: vi.fn() }),
|
||||
useAgentConfirmationModal: () => ({ openAgentConfirmationModal: openAgentConfirmationModalMock }),
|
||||
}));
|
||||
|
||||
vi.mock('../composables/useAgentPublish', () => ({
|
||||
|
|
@ -25,6 +30,16 @@ vi.mock('@n8n/i18n', () => ({
|
|||
useI18n: () => ({ baseText: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const favoritesStoreMock = {
|
||||
isFavorite: vi.fn(() => false),
|
||||
toggleFavorite: vi.fn().mockResolvedValue(undefined),
|
||||
removeFavoriteLocally: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/app/stores/favorites.store', () => ({
|
||||
useFavoritesStore: () => favoritesStoreMock,
|
||||
}));
|
||||
|
||||
const agentPermissionsMock = {
|
||||
canCreate: ref(true),
|
||||
canUpdate: ref(true),
|
||||
|
|
@ -54,8 +69,9 @@ const STUBS = {
|
|||
N8nActionToggle: {
|
||||
name: 'N8nActionToggle',
|
||||
template:
|
||||
'<div :data-test-id="$attrs[\'data-test-id\']"><button v-for="a in actions" :key="a.value" :data-action="a.value">{{ a.label }}</button></div>',
|
||||
'<div :data-test-id="$attrs[\'data-test-id\']"><button v-for="a in actions" :key="a.value" :data-action="a.value" @click="$emit(\'action\', a.value)">{{ a.label }}</button></div>',
|
||||
props: ['actions', 'theme'],
|
||||
emits: ['action'],
|
||||
},
|
||||
TimeAgo: { template: '<span />' },
|
||||
};
|
||||
|
|
@ -99,6 +115,11 @@ describe('AgentCard', () => {
|
|||
agentPermissionsMock.canDelete.value = true;
|
||||
agentPermissionsMock.canPublish.value = true;
|
||||
agentPermissionsMock.canUnpublish.value = true;
|
||||
favoritesStoreMock.isFavorite.mockReturnValue(false);
|
||||
favoritesStoreMock.toggleFavorite.mockClear();
|
||||
favoritesStoreMock.removeFavoriteLocally.mockClear();
|
||||
deleteAgentMock.mockClear();
|
||||
openAgentConfirmationModalMock.mockClear();
|
||||
});
|
||||
|
||||
it('hides the read-only badge when canUpdate is true', async () => {
|
||||
|
|
@ -115,13 +136,17 @@ describe('AgentCard', () => {
|
|||
expect(badge.text()).toBe('agents.list.readonly');
|
||||
});
|
||||
|
||||
it('hides the action toggle when no scopes grant any action', async () => {
|
||||
it('shows only the favorite toggle when no scopes grant publish or delete', async () => {
|
||||
agentPermissionsMock.canDelete.value = false;
|
||||
agentPermissionsMock.canPublish.value = false;
|
||||
agentPermissionsMock.canUnpublish.value = false;
|
||||
const wrapper = await renderComponent();
|
||||
|
||||
expect(wrapper.find('[data-test-id="agent-card-actions"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-test-id="agent-card-actions"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-action="toggleFavorite"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-action="publish"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-action="unpublish"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows Publish + Delete on an unpublished agent with full scopes', async () => {
|
||||
|
|
@ -157,4 +182,43 @@ describe('AgentCard', () => {
|
|||
expect(wrapper.find('[data-action="publish"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the favorite toggle action labeled "add" when the agent is not favorited', async () => {
|
||||
favoritesStoreMock.isFavorite.mockReturnValue(false);
|
||||
const wrapper = await renderComponent();
|
||||
|
||||
expect(favoritesStoreMock.isFavorite).toHaveBeenCalledWith('agent-1', 'agent');
|
||||
const toggle = wrapper.find('[data-action="toggleFavorite"]');
|
||||
expect(toggle.exists()).toBe(true);
|
||||
expect(toggle.text()).toBe('favorites.add');
|
||||
});
|
||||
|
||||
it('shows the favorite toggle action labeled "remove" when the agent is favorited', async () => {
|
||||
favoritesStoreMock.isFavorite.mockReturnValue(true);
|
||||
const wrapper = await renderComponent();
|
||||
|
||||
const toggle = wrapper.find('[data-action="toggleFavorite"]');
|
||||
expect(toggle.exists()).toBe(true);
|
||||
expect(toggle.text()).toBe('favorites.remove');
|
||||
});
|
||||
|
||||
it('toggles the favorite via the store when the toggle action is selected', async () => {
|
||||
favoritesStoreMock.isFavorite.mockReturnValue(false);
|
||||
const wrapper = await renderComponent();
|
||||
|
||||
await wrapper.find('[data-action="toggleFavorite"]').trigger('click');
|
||||
|
||||
expect(favoritesStoreMock.toggleFavorite).toHaveBeenCalledWith('agent-1', 'agent');
|
||||
});
|
||||
|
||||
it('removes the agent from local favorites after a successful delete', async () => {
|
||||
openAgentConfirmationModalMock.mockResolvedValue('confirm');
|
||||
const wrapper = await renderComponent();
|
||||
|
||||
await wrapper.find('[data-action="delete"]').trigger('click');
|
||||
|
||||
expect(deleteAgentMock).toHaveBeenCalledWith({}, 'project-1', 'agent-1');
|
||||
expect(favoritesStoreMock.removeFavoriteLocally).toHaveBeenCalledWith('agent-1', 'agent');
|
||||
expect(wrapper.emitted('deleted')).toEqual([['agent-1']]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useAgentPermissions } from '../composables/useAgentPermissions';
|
|||
import { useAgentPublish } from '../composables/useAgentPublish';
|
||||
import { removeProjectAgentFromListCache } from '../composables/useProjectAgentsList';
|
||||
import type { AgentResource } from '../types';
|
||||
import { useFavoritesStore } from '@/app/stores/favorites.store';
|
||||
|
||||
const props = defineProps<{
|
||||
agent: AgentResource;
|
||||
|
|
@ -35,6 +36,9 @@ const { canUpdate, canDelete, canPublish, canUnpublish } = useAgentPermissions(
|
|||
|
||||
const isPublished = computed(() => props.agent.activeVersionId !== null);
|
||||
|
||||
const favoriteStore = useFavoritesStore();
|
||||
const isFavorite = computed(() => favoriteStore.isFavorite(props.agent.id, 'agent'));
|
||||
|
||||
const actions = computed(() => {
|
||||
const items: Array<{ value: string; label: string; divided?: boolean }> = [];
|
||||
|
||||
|
|
@ -44,6 +48,11 @@ const actions = computed(() => {
|
|||
items.push({ value: 'publish', label: locale.baseText('agents.list.actions.publish') });
|
||||
}
|
||||
|
||||
items.push({
|
||||
value: 'toggleFavorite',
|
||||
label: locale.baseText(isFavorite.value ? 'favorites.remove' : 'favorites.add'),
|
||||
});
|
||||
|
||||
if (canDelete.value) {
|
||||
items.push({
|
||||
value: 'delete',
|
||||
|
|
@ -73,6 +82,8 @@ async function onAction(action: string) {
|
|||
} else if (action === 'unpublish') {
|
||||
const updated = await unpublish(props.projectId, props.agent.id, props.agent.name);
|
||||
if (updated) emit('unpublished', updated);
|
||||
} else if (action === 'toggleFavorite') {
|
||||
await favoriteStore.toggleFavorite(props.agent.id, 'agent');
|
||||
} else if (action === 'delete') {
|
||||
const confirmed = await openAgentConfirmationModal({
|
||||
title: locale.baseText('agents.delete.modal.title', {
|
||||
|
|
@ -87,6 +98,7 @@ async function onAction(action: string) {
|
|||
if (confirmed !== MODAL_CONFIRM) return;
|
||||
await deleteAgent(rootStore.restApiContext, props.projectId, props.agent.id);
|
||||
removeProjectAgentFromListCache(props.projectId, props.agent.id);
|
||||
favoriteStore.removeFavoriteLocally(props.agent.id, 'agent');
|
||||
emit('deleted', props.agent.id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useToast } from '@/app/composables/useToast';
|
|||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useFavoritesStore } from '@/app/stores/favorites.store';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { LOCAL_STORAGE_AGENT_BUILDER_CHAT_PANEL_WIDTH, MODAL_CONFIRM } from '@/app/constants';
|
||||
import { useResizablePanel } from '@/app/composables/useResizablePanel';
|
||||
|
|
@ -103,6 +104,7 @@ const sessionsStore = useAgentSessionsStore();
|
|||
const credentialsStore = useCredentialsStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const uiStore = useUIStore();
|
||||
const favoritesStore = useFavoritesStore();
|
||||
|
||||
// Gates the entire knowledge base feature (files panel + fetching) behind the
|
||||
// Daytona sandbox env vars on the backend (N8N_AGENTS_AI_SANDBOX_ENABLED + PROVIDER=daytona).
|
||||
|
|
@ -128,6 +130,7 @@ const agentId = computed(
|
|||
() =>
|
||||
(isArtifactMode.value ? props.artifactAgentId : undefined) ?? (route.params.agentId as string),
|
||||
);
|
||||
const isFavorite = computed(() => favoritesStore.isFavorite(agentId.value, 'agent'));
|
||||
|
||||
const { canUpdate: canEditAgent, canDelete: canDeleteAgent } = useAgentPermissions(projectId);
|
||||
|
||||
|
|
@ -252,6 +255,7 @@ watch(
|
|||
|
||||
function syncAgentIdentityFromConfig(c: AgentJsonConfig) {
|
||||
agentName.value = c.name;
|
||||
favoritesStore.renameFavorite(agentId.value, 'agent', c.name);
|
||||
if (!agent.value) return;
|
||||
agent.value = {
|
||||
...agent.value,
|
||||
|
|
@ -734,8 +738,7 @@ function onConfigFieldUpdate(updates: Partial<AgentJsonConfig>) {
|
|||
// Mirror identity edits onto the agent resource so the header reflects them
|
||||
// before the next fetch.
|
||||
if (updates.name !== undefined) {
|
||||
agentName.value = updates.name;
|
||||
if (agent.value) agent.value = { ...agent.value, name: updates.name };
|
||||
syncAgentIdentityFromConfig(localConfig.value);
|
||||
}
|
||||
configAutosave.scheduleAutosave({
|
||||
projectId: projectId.value,
|
||||
|
|
@ -873,6 +876,17 @@ const headerActions = computed(() => {
|
|||
});
|
||||
}
|
||||
|
||||
if (agent.value) {
|
||||
actions.push({
|
||||
id: 'toggleFavorite',
|
||||
label:
|
||||
isFavorite.value === true
|
||||
? locale.baseText('favorites.remove')
|
||||
: locale.baseText('favorites.add'),
|
||||
icon: isFavorite.value === true ? 'star-filled' : 'star',
|
||||
});
|
||||
}
|
||||
|
||||
if (canDeleteAgent.value) {
|
||||
actions.push({
|
||||
id: 'delete',
|
||||
|
|
@ -930,6 +944,10 @@ async function onHeaderAction(action: string) {
|
|||
openImportJsonModal();
|
||||
return;
|
||||
}
|
||||
if (action === 'toggleFavorite') {
|
||||
await favoritesStore.toggleFavorite(agentId.value, 'agent');
|
||||
return;
|
||||
}
|
||||
if (action === 'delete') {
|
||||
const confirmed = await openAgentConfirmationModal({
|
||||
title: locale.baseText('agents.delete.modal.title', {
|
||||
|
|
@ -951,6 +969,7 @@ async function onHeaderAction(action: string) {
|
|||
try {
|
||||
await deleteAgent(rootStore.restApiContext, capturedProjectId, agentId.value);
|
||||
removeProjectAgentFromListCache(capturedProjectId, agentId.value);
|
||||
favoritesStore.removeFavoriteLocally(agentId.value, 'agent');
|
||||
} catch (error) {
|
||||
showError(error, 'Could not delete agent');
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { UserFavorite } from '@/app/api/favorites';
|
|||
import { useFavoriteNavItems } from './useFavoriteNavItems';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { DATA_TABLE_DETAILS } from '@/features/core/dataTable/constants';
|
||||
import { AGENT_BUILDER_VIEW } from '@/features/agents/constants';
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>();
|
||||
|
|
@ -206,13 +207,57 @@ describe('useFavoriteNavItems', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('favoriteAgentItems', () => {
|
||||
it('should map agent favorites with projectId to menu items', () => {
|
||||
favoritesStore.favorites = [
|
||||
makeFavorite({
|
||||
resourceId: 'agent-1',
|
||||
resourceType: 'agent',
|
||||
resourceName: 'My Agent',
|
||||
resourceProjectId: 'proj-1',
|
||||
}),
|
||||
];
|
||||
|
||||
const { favoriteAgentItems } = useFavoriteNavItems();
|
||||
|
||||
expect(favoriteAgentItems.value).toHaveLength(1);
|
||||
expect(favoriteAgentItems.value[0].resourceId).toBe('agent-1');
|
||||
expect(favoriteAgentItems.value[0].resourceType).toBe('agent');
|
||||
expect(favoriteAgentItems.value[0].menuItem).toMatchObject({
|
||||
id: 'favorite-agent-agent-1',
|
||||
label: 'My Agent',
|
||||
icon: 'robot',
|
||||
route: {
|
||||
to: {
|
||||
name: AGENT_BUILDER_VIEW,
|
||||
params: { projectId: 'proj-1', agentId: 'agent-1' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should exclude agent favorites without resourceProjectId', () => {
|
||||
favoritesStore.favorites = [
|
||||
makeFavorite({
|
||||
resourceId: 'agent-1',
|
||||
resourceType: 'agent',
|
||||
resourceName: 'My Agent',
|
||||
resourceProjectId: undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
const { favoriteAgentItems } = useFavoriteNavItems();
|
||||
|
||||
expect(favoriteAgentItems.value).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('favoriteGroups', () => {
|
||||
it('should return empty array when no favorites', () => {
|
||||
const { favoriteGroups } = useFavoriteNavItems();
|
||||
|
||||
expect(favoriteGroups.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should include only groups with items', () => {
|
||||
favoritesStore.favorites = [
|
||||
makeFavorite({ resourceId: 'wf-1', resourceType: 'workflow', resourceName: 'Workflow 1' }),
|
||||
|
|
@ -224,7 +269,7 @@ describe('useFavoriteNavItems', () => {
|
|||
expect(favoriteGroups.value[0].type).toBe('workflow');
|
||||
});
|
||||
|
||||
it('should order groups: projects first, then folders, workflows, dataTables', () => {
|
||||
it('should order groups: projects first, then folders, workflows, dataTables, agents', () => {
|
||||
favoritesStore.favorites = [
|
||||
makeFavorite({ id: 1, resourceId: 'wf-1', resourceType: 'workflow' }),
|
||||
makeFavorite({
|
||||
|
|
@ -247,6 +292,13 @@ describe('useFavoriteNavItems', () => {
|
|||
resourceName: 'Folder 1',
|
||||
resourceProjectId: 'proj-1',
|
||||
}),
|
||||
makeFavorite({
|
||||
id: 5,
|
||||
resourceId: 'agent-1',
|
||||
resourceType: 'agent',
|
||||
resourceName: 'Agent 1',
|
||||
resourceProjectId: 'proj-1',
|
||||
}),
|
||||
];
|
||||
|
||||
const { favoriteGroups } = useFavoriteNavItems();
|
||||
|
|
@ -256,6 +308,7 @@ describe('useFavoriteNavItems', () => {
|
|||
'folder',
|
||||
'workflow',
|
||||
'dataTable',
|
||||
'agent',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { DEFAULT_PROJECT_ICON } from '../projects.constants';
|
|||
import type { Project } from '../projects.types';
|
||||
import { DATA_TABLE_DETAILS } from '@/features/core/dataTable/constants';
|
||||
import type { FavoriteResourceType } from '@/app/api/favorites';
|
||||
import { AGENT_BUILDER_VIEW } from '@/features/agents/constants';
|
||||
|
||||
export type FavoriteGroupItem = {
|
||||
menuItem: IMenuItem;
|
||||
|
|
@ -96,6 +97,26 @@ export function useFavoriteNavItems() {
|
|||
})),
|
||||
);
|
||||
|
||||
const favoriteAgentItems = computed<FavoriteGroupItem[]>(() =>
|
||||
favoritesStore.favorites
|
||||
.filter((f) => f.resourceType === 'agent' && f.resourceProjectId)
|
||||
.map((f) => ({
|
||||
menuItem: {
|
||||
id: `favorite-agent-${f.resourceId}`,
|
||||
label: f.resourceName,
|
||||
icon: 'robot' as IMenuItem['icon'],
|
||||
route: {
|
||||
to: {
|
||||
name: AGENT_BUILDER_VIEW,
|
||||
params: { projectId: f.resourceProjectId, agentId: f.resourceId },
|
||||
},
|
||||
},
|
||||
},
|
||||
resourceId: f.resourceId,
|
||||
resourceType: 'agent',
|
||||
})),
|
||||
);
|
||||
|
||||
const favoriteGroups = computed<FavoriteGroup[]>(() => {
|
||||
const groups: FavoriteGroup[] = [];
|
||||
if (favoriteProjectItems.value.length > 0) {
|
||||
|
|
@ -122,6 +143,12 @@ export function useFavoriteNavItems() {
|
|||
items: favoriteDataTableItems.value,
|
||||
});
|
||||
}
|
||||
if (favoriteAgentItems.value.length > 0) {
|
||||
groups.push({
|
||||
type: 'agent',
|
||||
items: favoriteAgentItems.value,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
|
|
@ -150,6 +177,7 @@ export function useFavoriteNavItems() {
|
|||
favoriteProjectItems,
|
||||
favoriteDataTableItems,
|
||||
favoriteFolderItems,
|
||||
favoriteAgentItems,
|
||||
favoriteGroups,
|
||||
activeTabId,
|
||||
onFavoriteProjectClick,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user