mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(web): introduce Automations tab with dual-track capability for routines This commit adds a new Automations tab that consolidates routines, schedules, and live artifacts, allowing users to manage automations seamlessly. The tab features a modal for creating and editing automations, which supports various scheduling options (hourly, daily, weekdays, weekly) and project modes (create_each_run, reuse). The CLI is also updated to expose automation commands, ensuring consistency between the web UI and CLI interfaces. Key changes include: - New `NewAutomationModal` component for automation creation and editing. - Updated `TasksView` to integrate the new Automations functionality. - Enhanced styling for the Automations tab to improve user experience. This implementation aligns with the dual-track capability exposure policy, ensuring all features are accessible via both the web UI and CLI. * feat(daemon): enhance automation context handling and CLI commands This commit introduces several improvements to the automation context management and updates the CLI commands accordingly. Key changes include: - Added support for new context fields (`plugin`, `mcp`, `connector`) in automation commands. - Updated the CLI to reflect new target options (`new-project`). - Enhanced error messages for invalid target inputs. - Introduced functions to handle context selection and normalization for routines, including the ability to parse and store context data in the database. - Updated the database schema to include a new `context_json` field for routines. - Improved the handling of context in routine routes and the web interface, ensuring that selected contexts are properly managed and displayed. These changes aim to provide a more robust and flexible automation experience, aligning with the recent enhancements in the web UI. * feat(web): enhance TasksView with automation run history and status indicators This commit introduces several new features to the TasksView component, including: - Added functionality to display automation run history for each routine, showing metadata such as status, timestamps, and project details. - Implemented status indicators for routine runs, providing visual feedback on their current state (succeeded, failed, running, queued). - Enhanced the UI to allow users to expand and view detailed run history, including the ability to open the corresponding project conversation. - Updated styles to improve the presentation of automation statuses and history. These changes aim to provide users with better insights into their automation routines and improve overall usability. * feat(daemon): implement automation ingestion and proposal management This commit introduces several new features related to automation ingestion and proposal management within the daemon. Key changes include: - Added new modules for handling automation source packets and proposals, allowing for the storage, retrieval, and management of automation-related data. - Implemented functions to list, create, and apply automation proposals, enhancing the automation workflow. - Introduced new CLI commands for interacting with memory entries and automation sources, providing users with more control over their automation processes. - Enhanced the server routes to support automation source and proposal APIs, enabling seamless integration with the existing system. These changes aim to improve the overall automation experience, making it easier for users to manage and utilize automation proposals and ingestions effectively.
303 lines
9.6 KiB
TypeScript
303 lines
9.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import type { ComponentProps } from 'react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ChatComposer } from '../../src/components/ChatComposer';
|
|
|
|
const COMMUNITY_PLUGIN = {
|
|
id: 'community-deck',
|
|
title: 'Community Deck',
|
|
version: '1.0.0',
|
|
trust: 'restricted' as const,
|
|
sourceKind: 'bundled' as const,
|
|
source: 'bundled/community-deck',
|
|
capabilitiesGranted: [],
|
|
manifest: {
|
|
name: 'community-deck',
|
|
title: 'Community Deck',
|
|
description: 'Official deck starter',
|
|
od: { kind: 'skill' },
|
|
},
|
|
fsPath: '/plugins/community-deck',
|
|
installedAt: 0,
|
|
updatedAt: 0,
|
|
};
|
|
|
|
const USER_PLUGIN = {
|
|
...COMMUNITY_PLUGIN,
|
|
id: 'my-export',
|
|
title: 'My Export',
|
|
sourceKind: 'local' as const,
|
|
source: '/plugins/my-export',
|
|
manifest: {
|
|
...COMMUNITY_PLUGIN.manifest,
|
|
name: 'my-export',
|
|
title: 'My Export',
|
|
description: 'Private export workflow',
|
|
},
|
|
};
|
|
|
|
const SKILL = {
|
|
id: 'deck-builder',
|
|
name: 'Deck Builder',
|
|
description: 'Build a polished slide deck.',
|
|
triggers: ['deck'],
|
|
mode: 'deck' as const,
|
|
previewType: 'html',
|
|
designSystemRequired: false,
|
|
defaultFor: [],
|
|
upstream: null,
|
|
hasBody: true,
|
|
examplePrompt: 'Make a deck',
|
|
aggregatesExamples: false,
|
|
};
|
|
|
|
function makeSkill(overrides: Partial<typeof SKILL>): typeof SKILL {
|
|
return {
|
|
...SKILL,
|
|
id: overrides.id ?? SKILL.id,
|
|
name: overrides.name ?? SKILL.name,
|
|
description: overrides.description ?? SKILL.description,
|
|
triggers: overrides.triggers ?? SKILL.triggers,
|
|
mode: overrides.mode ?? SKILL.mode,
|
|
previewType: overrides.previewType ?? SKILL.previewType,
|
|
designSystemRequired: overrides.designSystemRequired ?? SKILL.designSystemRequired,
|
|
defaultFor: overrides.defaultFor ?? SKILL.defaultFor,
|
|
upstream: overrides.upstream ?? SKILL.upstream,
|
|
hasBody: overrides.hasBody ?? SKILL.hasBody,
|
|
examplePrompt: overrides.examplePrompt ?? SKILL.examplePrompt,
|
|
aggregatesExamples: overrides.aggregatesExamples ?? SKILL.aggregatesExamples,
|
|
};
|
|
}
|
|
|
|
const MCP_SERVER = {
|
|
id: 'slack',
|
|
label: 'Slack MCP',
|
|
transport: 'stdio' as const,
|
|
enabled: true,
|
|
command: 'slack-mcp',
|
|
};
|
|
|
|
const APPLY_RESULT = {
|
|
ok: true,
|
|
query: 'Run plugin.',
|
|
contextItems: [],
|
|
inputs: [],
|
|
assets: [],
|
|
mcpServers: [],
|
|
trust: 'restricted',
|
|
capabilitiesGranted: ['prompt:inject'],
|
|
capabilitiesRequired: ['prompt:inject'],
|
|
appliedPlugin: {
|
|
snapshotId: 'snap-1',
|
|
pluginId: USER_PLUGIN.id,
|
|
pluginVersion: '1.0.0',
|
|
manifestSourceDigest: 'a'.repeat(64),
|
|
inputs: {},
|
|
resolvedContext: { items: [] },
|
|
capabilitiesGranted: ['prompt:inject'],
|
|
capabilitiesRequired: ['prompt:inject'],
|
|
assetsStaged: [],
|
|
taskKind: 'new-generation',
|
|
appliedAt: 0,
|
|
connectorsRequired: [],
|
|
connectorsResolved: [],
|
|
mcpServers: [],
|
|
status: 'fresh',
|
|
},
|
|
projectMetadata: {},
|
|
};
|
|
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
let plugins = [COMMUNITY_PLUGIN, USER_PLUGIN];
|
|
let skills = [SKILL];
|
|
let servers = [MCP_SERVER];
|
|
|
|
function renderComposer(overrides: Partial<ComponentProps<typeof ChatComposer>> = {}) {
|
|
return render(
|
|
<ChatComposer
|
|
projectId="project-1"
|
|
projectFiles={[]}
|
|
streaming={false}
|
|
onEnsureProject={async () => 'project-1'}
|
|
onSend={vi.fn()}
|
|
onStop={vi.fn()}
|
|
onOpenMcpSettings={vi.fn()}
|
|
skills={skills}
|
|
{...overrides}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
plugins = [COMMUNITY_PLUGIN, USER_PLUGIN];
|
|
skills = [SKILL];
|
|
servers = [MCP_SERVER];
|
|
fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
if (url === '/api/mcp/servers') {
|
|
return new Response(JSON.stringify({ servers, templates: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url.includes('/api/plugins/') && url.endsWith('/apply')) {
|
|
return new Response(JSON.stringify(APPLY_RESULT), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/skills') {
|
|
return new Response(JSON.stringify({ skills }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/projects/project-1' && init?.method === 'PATCH') {
|
|
return new Response(JSON.stringify({ project: { id: 'project-1', skillId: SKILL.id } }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
cleanup();
|
|
});
|
|
|
|
describe('ChatComposer context pickers', () => {
|
|
it('opens the @ panel even when every source is empty', async () => {
|
|
plugins = [];
|
|
skills = [];
|
|
servers = [];
|
|
renderComposer();
|
|
|
|
fireEvent.change(screen.getByTestId('chat-composer-input'), {
|
|
target: { value: '@', selectionStart: 1 },
|
|
});
|
|
|
|
expect(screen.getByTestId('mention-popover')).toBeTruthy();
|
|
expect(screen.getByRole('tab', { name: 'Plugins' })).toBeTruthy();
|
|
expect(screen.getByRole('tab', { name: 'Skills' })).toBeTruthy();
|
|
expect(screen.getByRole('tab', { name: 'MCP' })).toBeTruthy();
|
|
expect(screen.getByRole('tab', { name: 'Connectors' })).toBeTruthy();
|
|
expect(screen.getByRole('tab', { name: 'Design files' })).toBeTruthy();
|
|
expect(screen.getByText('Search plugins, skills, MCP servers, connectors, and Design Files.')).toBeTruthy();
|
|
});
|
|
|
|
it('selects an MCP server from @ search and keeps the inline token visible', async () => {
|
|
renderComposer();
|
|
const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
|
|
|
|
fireEvent.change(input, {
|
|
target: { value: '@sl', selectionStart: 3 },
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('Slack MCP')).toBeTruthy());
|
|
fireEvent.click(screen.getByText('Slack MCP'));
|
|
|
|
expect(input.value).toBe('@Slack MCP ');
|
|
expect(screen.getByTestId('chat-composer-mention-overlay').textContent).toContain('@Slack MCP');
|
|
});
|
|
|
|
it('applies a skill from @ search and reports the active project skill', async () => {
|
|
const onProjectSkillChange = vi.fn();
|
|
renderComposer({ onProjectSkillChange });
|
|
const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
|
|
|
|
fireEvent.change(input, {
|
|
target: { value: '@deck', selectionStart: 5 },
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('Deck Builder')).toBeTruthy());
|
|
fireEvent.click(screen.getByText('Deck Builder'));
|
|
|
|
await waitFor(() => expect(onProjectSkillChange).toHaveBeenCalledWith('deck-builder'));
|
|
expect(input.value).toBe('@Deck Builder ');
|
|
expect(screen.getByTestId('chat-composer-mention-overlay').textContent).toContain('@Deck Builder');
|
|
});
|
|
|
|
it('shows all matching skills and ranks exact prefix matches first', async () => {
|
|
skills = [
|
|
makeSkill({
|
|
id: 'story-brief',
|
|
name: 'Story Brief',
|
|
description: 'Use when planning audit work.',
|
|
triggers: ['writing'],
|
|
}),
|
|
...Array.from({ length: 9 }, (_, index) =>
|
|
makeSkill({
|
|
id: `audit-helper-${index + 1}`,
|
|
name: `Audit Helper ${index + 1}`,
|
|
description: `Audit support workflow ${index + 1}.`,
|
|
triggers: [`audit-${index + 1}`],
|
|
}),
|
|
),
|
|
makeSkill({
|
|
id: 'accessibility-review',
|
|
name: 'Accessibility Review',
|
|
description: 'Audit accessible interaction details.',
|
|
triggers: ['a11y-audit'],
|
|
}),
|
|
];
|
|
renderComposer();
|
|
const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
|
|
|
|
fireEvent.change(input, {
|
|
target: { value: '@audit', selectionStart: 6 },
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('Audit Helper 9')).toBeTruthy());
|
|
const skillNames = Array.from(
|
|
screen.getByTestId('mention-popover').querySelectorAll('.mention-item strong'),
|
|
(node) => node.textContent,
|
|
);
|
|
|
|
expect(skillNames).toContain('Audit Helper 9');
|
|
expect(skillNames.indexOf('Audit Helper 1')).toBeLessThan(skillNames.indexOf('Story Brief'));
|
|
expect(skillNames.indexOf('Audit Helper 9')).toBeLessThan(skillNames.indexOf('Accessibility Review'));
|
|
});
|
|
|
|
it('applies a plugin from @ search and keeps the plugin token inline', async () => {
|
|
renderComposer();
|
|
const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
|
|
|
|
fireEvent.change(input, {
|
|
target: { value: '@export', selectionStart: 7 },
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('My Export')).toBeTruthy());
|
|
fireEvent.click(screen.getByText('My Export'));
|
|
|
|
await waitFor(() => expect(input.value).toBe('@My Export '));
|
|
expect(screen.getByTestId('chat-composer-mention-overlay').textContent).toContain('@My Export');
|
|
});
|
|
|
|
it('lets the tools panel switch between Official and My plugins', async () => {
|
|
renderComposer();
|
|
fireEvent.click(screen.getByLabelText('Open CLI and model settings'));
|
|
|
|
await waitFor(() => expect(screen.getByText('Community Deck')).toBeTruthy());
|
|
expect(screen.queryByText('My Export')).toBeNull();
|
|
|
|
fireEvent.click(screen.getByText('My plugins'));
|
|
expect(screen.getByText('My Export')).toBeTruthy();
|
|
expect(screen.queryByText('Community Deck')).toBeNull();
|
|
|
|
fireEvent.change(screen.getByLabelText('Search plugins'), {
|
|
target: { value: 'private' },
|
|
});
|
|
expect(screen.getByText('Private export workflow')).toBeTruthy();
|
|
});
|
|
});
|