mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
- Added support for displaying an active plugin as a context chip in user messages when a project is created with a pinned plugin. This replaces the in-composer plugin rail to avoid re-prompting users for plugin selection. - Introduced `applied_plugin_snapshot_id` in the database schema and updated relevant components (ChatComposer, ChatPane, ProjectView) to handle the new functionality. - Implemented fetching of the applied plugin snapshot in ProjectView to ensure the active plugin is rendered correctly. - Enhanced CSS for the plugin chip to improve visual presentation. This change streamlines the user experience by providing context on previously selected plugins directly within the chat interface.
235 lines
8.2 KiB
TypeScript
235 lines
8.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, render, waitFor } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { ProjectView, resolveSucceededRunStatus } from '../../src/components/ProjectView';
|
|
|
|
const listConversations = vi.fn();
|
|
const listMessages = vi.fn();
|
|
const fetchPreviewComments = vi.fn();
|
|
const loadTabs = vi.fn();
|
|
const fetchProjectFiles = vi.fn();
|
|
const fetchLiveArtifacts = vi.fn();
|
|
const fetchSkill = vi.fn();
|
|
const fetchDesignSystem = vi.fn();
|
|
const getTemplate = vi.fn();
|
|
const fetchChatRunStatus = vi.fn();
|
|
const listActiveChatRuns = vi.fn();
|
|
const reattachDaemonRun = vi.fn();
|
|
const saveMessage = vi.fn();
|
|
const createConversation = vi.fn();
|
|
const patchConversation = vi.fn();
|
|
const patchProject = vi.fn();
|
|
const saveTabs = vi.fn();
|
|
|
|
vi.mock('../../src/i18n', () => ({
|
|
useT: () => ((value: string) => value),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/anthropic', () => ({
|
|
streamMessage: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/daemon', () => ({
|
|
fetchChatRunStatus: (...args: unknown[]) => fetchChatRunStatus(...args),
|
|
listActiveChatRuns: (...args: unknown[]) => listActiveChatRuns(...args),
|
|
reattachDaemonRun: (...args: unknown[]) => reattachDaemonRun(...args),
|
|
streamViaDaemon: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/registry', () => ({
|
|
deletePreviewComment: vi.fn(),
|
|
fetchPreviewComments: (...args: unknown[]) => fetchPreviewComments(...args),
|
|
fetchDesignSystem: (...args: unknown[]) => fetchDesignSystem(...args),
|
|
fetchLiveArtifacts: (...args: unknown[]) => fetchLiveArtifacts(...args),
|
|
fetchProjectFiles: (...args: unknown[]) => fetchProjectFiles(...args),
|
|
fetchSkill: (...args: unknown[]) => fetchSkill(...args),
|
|
patchPreviewCommentStatus: vi.fn(),
|
|
upsertPreviewComment: vi.fn(),
|
|
writeProjectTextFile: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/project-events', () => ({
|
|
useProjectFileEvents: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/router', () => ({
|
|
navigate: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/state/projects', () => ({
|
|
createConversation: (...args: unknown[]) => createConversation(...args),
|
|
deleteConversation: vi.fn(),
|
|
getTemplate: (...args: unknown[]) => getTemplate(...args),
|
|
listConversations: (...args: unknown[]) => listConversations(...args),
|
|
listMessages: (...args: unknown[]) => listMessages(...args),
|
|
loadTabs: (...args: unknown[]) => loadTabs(...args),
|
|
patchConversation: (...args: unknown[]) => patchConversation(...args),
|
|
patchProject: (...args: unknown[]) => patchProject(...args),
|
|
saveMessage: (...args: unknown[]) => saveMessage(...args),
|
|
saveTabs: (...args: unknown[]) => saveTabs(...args),
|
|
}));
|
|
|
|
vi.mock('../../src/components/AppChromeHeader', () => ({
|
|
AppChromeHeader: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/AvatarMenu', () => ({
|
|
AvatarMenu: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/ChatPane', () => ({
|
|
ChatPane: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/FileWorkspace', () => ({
|
|
FileWorkspace: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/Loading', () => ({
|
|
CenteredLoader: () => null,
|
|
}));
|
|
|
|
describe('ProjectView daemon cleanup', () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('does not abort daemon cancel reattach controllers during unmount cleanup', async () => {
|
|
let seenCancelSignal: { aborted: boolean } | null = null;
|
|
let seenSignal: { aborted: boolean } | null = null;
|
|
|
|
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
|
|
listMessages.mockResolvedValue([
|
|
{
|
|
id: 'msg-1',
|
|
role: 'assistant',
|
|
content: 'working',
|
|
createdAt: Date.now(),
|
|
runId: 'run-1',
|
|
runStatus: 'running',
|
|
},
|
|
]);
|
|
fetchPreviewComments.mockResolvedValue([]);
|
|
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
|
|
fetchProjectFiles.mockResolvedValue([]);
|
|
fetchLiveArtifacts.mockResolvedValue([]);
|
|
fetchSkill.mockResolvedValue(null);
|
|
fetchDesignSystem.mockResolvedValue(null);
|
|
getTemplate.mockResolvedValue(null);
|
|
fetchChatRunStatus.mockResolvedValue({
|
|
id: 'run-1',
|
|
status: 'running',
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
exitCode: null,
|
|
signal: null,
|
|
});
|
|
listActiveChatRuns.mockResolvedValue([]);
|
|
reattachDaemonRun.mockImplementation(async (options: { signal: { aborted: boolean }; cancelSignal?: { aborted: boolean } }) => {
|
|
seenSignal = options.signal;
|
|
seenCancelSignal = options.cancelSignal ?? null;
|
|
return new Promise<void>(() => {});
|
|
});
|
|
|
|
const view = render(
|
|
<ProjectView
|
|
project={{ id: 'project-1', name: 'Project', skillId: null, designSystemId: null } as never}
|
|
routeFileName={null}
|
|
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
|
|
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
|
|
skills={[]}
|
|
designSystems={[]}
|
|
daemonLive
|
|
onModeChange={() => {}}
|
|
onAgentChange={() => {}}
|
|
onAgentModelChange={() => {}}
|
|
onRefreshAgents={() => {}}
|
|
onOpenSettings={() => {}}
|
|
onBack={() => {}}
|
|
onClearPendingPrompt={() => {}}
|
|
onTouchProject={() => {}}
|
|
onProjectChange={() => {}}
|
|
onProjectsRefresh={() => {}}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1));
|
|
expect(seenSignal).not.toBeNull();
|
|
expect(seenCancelSignal).not.toBeNull();
|
|
|
|
view.unmount();
|
|
|
|
if (!seenSignal || !seenCancelSignal) throw new Error('Expected reattach signals to be captured');
|
|
expect((seenSignal as any).aborted).toBe(true);
|
|
expect((seenCancelSignal as any).aborted).toBe(false);
|
|
});
|
|
|
|
it('marks successful daemon completion as succeeded even before runId reaches message state', () => {
|
|
expect(resolveSucceededRunStatus('running')).toBe('succeeded');
|
|
expect(resolveSucceededRunStatus('queued')).toBe('succeeded');
|
|
expect(resolveSucceededRunStatus(undefined)).toBe('succeeded');
|
|
expect(resolveSucceededRunStatus('failed')).toBe('failed');
|
|
expect(resolveSucceededRunStatus('canceled')).toBe('canceled');
|
|
});
|
|
|
|
// Regression: a phantom 'running' row in DB (no runId, no matching active
|
|
// daemon run) used to stick the UI on "Waiting for first output —
|
|
// Working 24m+" forever. The reattach loop now self-heals by marking
|
|
// such a message as failed so the composer is interactive again.
|
|
it('self-heals running messages with no runId when daemon has no active run', async () => {
|
|
const startedAt = Date.now();
|
|
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
|
|
listMessages.mockResolvedValue([
|
|
{
|
|
id: 'msg-phantom',
|
|
role: 'assistant',
|
|
content: '',
|
|
createdAt: startedAt,
|
|
startedAt,
|
|
runStatus: 'running',
|
|
},
|
|
]);
|
|
fetchPreviewComments.mockResolvedValue([]);
|
|
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
|
|
fetchProjectFiles.mockResolvedValue([]);
|
|
fetchLiveArtifacts.mockResolvedValue([]);
|
|
fetchSkill.mockResolvedValue(null);
|
|
fetchDesignSystem.mockResolvedValue(null);
|
|
getTemplate.mockResolvedValue(null);
|
|
listActiveChatRuns.mockResolvedValue([]);
|
|
|
|
render(
|
|
<ProjectView
|
|
project={{ id: 'project-1', name: 'Project', skillId: null, designSystemId: null } as never}
|
|
routeFileName={null}
|
|
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
|
|
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
|
|
skills={[]}
|
|
designSystems={[]}
|
|
daemonLive
|
|
onModeChange={() => {}}
|
|
onAgentChange={() => {}}
|
|
onAgentModelChange={() => {}}
|
|
onRefreshAgents={() => {}}
|
|
onOpenSettings={() => {}}
|
|
onBack={() => {}}
|
|
onClearPendingPrompt={() => {}}
|
|
onTouchProject={() => {}}
|
|
onProjectChange={() => {}}
|
|
onProjectsRefresh={() => {}}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(listActiveChatRuns).toHaveBeenCalled());
|
|
await waitFor(() => {
|
|
const failedCall = saveMessage.mock.calls.find(
|
|
(call) =>
|
|
call[2]?.id === 'msg-phantom' && call[2]?.runStatus === 'failed',
|
|
);
|
|
expect(failedCall).toBeTruthy();
|
|
});
|
|
expect(reattachDaemonRun).not.toHaveBeenCalled();
|
|
});
|
|
});
|