feat(core): Add agent favorites (#33899)

This commit is contained in:
Anne Aguirre 2026-07-10 14:57:16 +01:00 committed by GitHub
parent d06c2b7f09
commit ff5d4f1d3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 455 additions and 17 deletions

View File

@ -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;

View File

@ -1035,6 +1035,12 @@ export type RelayEventMap = {
// #endregion
// region Agents
'agent-deleted': {
agentId: string;
projectId: string;
};
// #region Instance Policies
'instance-policies-updated': { user: UserLike } & (

View File

@ -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(

View File

@ -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 () => {

View File

@ -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);

View File

@ -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');
});
});
});

View File

@ -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);

View File

@ -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'),
});
}
}

View File

@ -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) {

View File

@ -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;

View File

@ -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([]);
});
});
});

View File

@ -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,

View File

@ -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();

View File

@ -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']]);
});
});

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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',
]);
});
});

View File

@ -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,