mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(daemon): add project working directory management and editor hand-off functionality - Introduced new flags for project commands to manage working directories, including `--working-dir` and `--dir`. - Implemented API routes for listing available editors and opening projects in selected editors. - Added a hand-off button in the ChatPane header to facilitate opening project folders in local applications. - Enhanced the HomeHero component to include working directory and design system settings, improving user experience in project creation. - Created HomeHeroSettingsChips component for inline management of working directory and design system selection. * feat(chat): implement voice transcription proxy and enhance UI components - Added a new API route for voice transcription using OpenAI's `/audio/transcriptions` endpoint, allowing users to send audio blobs directly for transcription. - Integrated multer for handling audio file uploads in memory, ensuring efficient processing without disk storage. - Updated the HomeHero component to include example prompt suggestions for plugins, enhancing user interaction. - Introduced the EditorIcon component to visually represent different editors in the hand-off menu, improving the user experience. - Refined the HandoffButton component to utilize the new EditorIcon, providing a more cohesive interface for selecting editors. - Enhanced CSS styles for various components to improve layout and responsiveness, including adjustments to tab and button sizes for better usability. * style(workspace-shell): enhance layout and overflow handling - Updated CSS for .workspace-shell to ensure full viewport width and height, with proper overflow management. - Adjusted grid layout to prevent content overflow and maintain responsiveness. - Modified styles for .workspace-tabs-chrome to improve width handling and prevent overflow issues. * refactor(chat): remove voice transcription proxy and related components - Deleted the voice transcription proxy implementation, including the associated API route and multer configuration. - Removed the MicButton component from the ChatComposer and HomeHero components to streamline the UI. - Updated HomeHero to include example suggestions without the voice input functionality. - Adjusted CSS styles for various components to maintain layout consistency after the removal of the MicButton. * feat(daemon): implement minting of HMAC tokens for working directory management - Added a new function `mintImportTokenFromCurrentSecret` to generate HMAC tokens bound to a specified base directory, enhancing security for working directory operations. - Updated the `desktop-auth.ts` file to include the new token minting functionality, which returns structured errors when the desktop auth secret is cleared. - Introduced new IPC message types for minting import tokens in the sidecar protocol, allowing seamless integration with the daemon's working directory management. - Enhanced the `WorkingDirPill` component to utilize the new token minting flow for secure directory selection in desktop builds. - Updated CSS styles for the HomeHero component to accommodate new example suggestion features and maintain layout consistency. * fix(HomeView): import HOME_HERO_CHIPS constant for improved chip management - Updated the HomeView component to import the HOME_HERO_CHIPS constant from the chips module, enhancing the management of hero chips within the component. * feat(daemon): implement mintImportTokenViaSidecar for secure working directory management - Introduced the `mintImportTokenViaSidecar` function to facilitate the minting of HMAC tokens for desktop-import operations via the daemon's sidecar IPC. This allows CLI commands to bypass authentication when the desktop-auth gate is active. - Updated the CLI to utilize the new token minting function when setting the working directory, ensuring secure access to trust-gated API endpoints. - Enhanced the sidecar server to handle minting requests and return structured error messages for improved user feedback. - Added tests to validate the new token minting functionality and its integration with the working directory management process. - Refactored related components to support the new token flow, improving overall security and user experience. * feat(HomeHero): enhance UI components and styles for improved user experience - Updated HomeHero component to replace active dot indicators with Plug icons for better visual representation of active plugins. - Adjusted CSS styles for various elements, including padding and dimensions, to enhance layout consistency and responsiveness. - Introduced new styles for active type icons and improved hover effects for buttons. - Updated HomeHeroSettingsChips to change button titles and icons for clarity. - Added tests to ensure proper rendering and functionality of updated components. * feat(ProjectDesignSystemPicker): enhance design system selection with preview functionality - Updated the ProjectDesignSystemPicker component to include a preview feature for design systems, allowing users to see a preview of the selected design system. - Implemented hover functionality to update the preview based on the hovered design system. - Added fullscreen preview capability for a more immersive experience. - Enhanced CSS styles for the design system picker to improve layout and responsiveness. - Introduced tests to validate the new preview functionality and ensure proper interaction within the component. * feat: refactor project metadata handling and enhance design system picker - Updated the default scenario plugin ID retrieval to use project metadata, improving the logic for determining the appropriate plugin based on project intent. - Enhanced the ProjectDesignSystemPicker and related components to support localized design system summaries and categories, improving user experience. - Introduced new translations for working directory and design system picker components, ensuring better accessibility and usability across different locales. - Added a new 'live-artifact' project type to the HomeHero chips, expanding the functionality for users creating refreshable artifacts. - Updated tests to validate the new project metadata handling and design system picker functionalities. * feat: enhance localization and styling for design system components - Added French translations for working directory and design system picker components, improving accessibility for French-speaking users. - Updated CSS styles for the pet task item to ensure consistent padding and layout. - Introduced a new test suite for HomeHeroSettingsChips to validate localization and design system selection functionality. - Enhanced ProjectDesignSystemPicker tests to ensure proper localization and interaction with design system categories. * fix: update .gitignore to include all claude-sessions directories and remove specific session files - Modified .gitignore to ensure all claude-sessions directories are ignored by using a wildcard pattern. - Deleted two specific claude-sessions markdown files to clean up unnecessary session data. * fix: repair home automation ci regressions * fix: stabilize artifact consistency e2e * Remove folder picker changes from PR 2400 --------- Co-authored-by: pftom <1043269994@qq.com> Co-authored-by: qiongyu1999 <2694684348@qq.com>
514 lines
18 KiB
TypeScript
514 lines
18 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import type { ReactNode } from 'react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ProjectView } from '../../src/components/ProjectView';
|
|
import type { AppConfig, ChatMessage, Conversation, Project } from '../../src/types';
|
|
|
|
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 listProjectRuns = vi.fn();
|
|
const reattachDaemonRun = vi.fn();
|
|
const streamViaDaemon = vi.fn();
|
|
const streamMessage = vi.fn();
|
|
const saveMessage = vi.fn();
|
|
const createConversation = vi.fn();
|
|
const patchConversation = vi.fn();
|
|
const patchProject = vi.fn();
|
|
const saveTabs = vi.fn();
|
|
const playSound = vi.fn();
|
|
const showCompletionNotification = vi.fn();
|
|
|
|
vi.mock('../../src/i18n', () => ({
|
|
useI18n: () => ({
|
|
locale: 'en',
|
|
setLocale: () => undefined,
|
|
t: (key: string) => key,
|
|
}),
|
|
useT: () => (key: string) => key,
|
|
}));
|
|
|
|
vi.mock('../../src/providers/anthropic', () => ({
|
|
streamMessage: (...args: unknown[]) => streamMessage(...args),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/daemon', () => ({
|
|
fetchChatRunStatus: (...args: unknown[]) => fetchChatRunStatus(...args),
|
|
listActiveChatRuns: (...args: unknown[]) => listActiveChatRuns(...args),
|
|
listProjectRuns: (...args: unknown[]) => listProjectRuns(...args),
|
|
reattachDaemonRun: (...args: unknown[]) => reattachDaemonRun(...args),
|
|
streamViaDaemon: (...args: unknown[]) => streamViaDaemon(...args),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/project-events', () => ({
|
|
useProjectFileEvents: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/utils/notifications', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/utils/notifications')>()),
|
|
playSound: (...args: unknown[]) => playSound(...args),
|
|
showCompletionNotification: (...args: unknown[]) => showCompletionNotification(...args),
|
|
}));
|
|
|
|
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/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: ({ children }: { children: ReactNode }) => <header>{children}</header>,
|
|
}));
|
|
|
|
vi.mock('../../src/components/AvatarMenu', () => ({
|
|
AvatarMenu: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/FileWorkspace', () => ({
|
|
FileWorkspace: ({
|
|
streaming,
|
|
onSendBoardCommentAttachments,
|
|
}: {
|
|
streaming: boolean;
|
|
onSendBoardCommentAttachments: (attachments: unknown[]) => void;
|
|
}) => (
|
|
<>
|
|
<output data-testid="workspace-streaming-state">{streaming ? 'streaming' : 'idle'}</output>
|
|
<button
|
|
type="button"
|
|
data-testid="workspace-send-comment"
|
|
onClick={() => onSendBoardCommentAttachments([{ id: 'comment-1' }])}
|
|
>
|
|
workspace send
|
|
</button>
|
|
</>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../../src/components/Loading', () => ({
|
|
CenteredLoader: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/ChatPane', () => ({
|
|
ChatPane: ({
|
|
activeConversationId,
|
|
conversations,
|
|
streaming,
|
|
sendDisabled,
|
|
onSelectConversation,
|
|
onSend,
|
|
onNewConversation,
|
|
error,
|
|
}: {
|
|
activeConversationId: string | null;
|
|
conversations: Conversation[];
|
|
streaming: boolean;
|
|
sendDisabled?: boolean;
|
|
error: string | null;
|
|
onSelectConversation: (id: string) => void;
|
|
onSend: (prompt: string, attachments: unknown[], commentAttachments: unknown[]) => void;
|
|
onNewConversation: () => void;
|
|
}) => (
|
|
<section>
|
|
<output data-testid="active-conversation">{activeConversationId}</output>
|
|
<output data-testid="streaming-state">{streaming ? 'streaming' : 'idle'}</output>
|
|
<output data-testid="chat-error">{error}</output>
|
|
{conversations.map((conversation) => (
|
|
<button
|
|
key={conversation.id}
|
|
type="button"
|
|
data-testid={`conversation-select-${conversation.id}`}
|
|
onClick={() => onSelectConversation(conversation.id)}
|
|
>
|
|
{conversation.id}
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
data-testid="send-message"
|
|
onClick={() => onSend('hello from b', [], [])}
|
|
disabled={sendDisabled}
|
|
>
|
|
send
|
|
</button>
|
|
<button type="button" data-testid="new-conversation" onClick={onNewConversation}>
|
|
new
|
|
</button>
|
|
</section>
|
|
),
|
|
}));
|
|
|
|
const config: AppConfig = {
|
|
mode: 'daemon',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
model: '',
|
|
agentId: 'agent-1',
|
|
agentModels: {},
|
|
skillId: null,
|
|
designSystemId: null,
|
|
notifications: {
|
|
soundEnabled: true,
|
|
successSoundId: 'success-sound',
|
|
failureSoundId: 'failure-sound',
|
|
desktopEnabled: false,
|
|
},
|
|
};
|
|
|
|
const project: Project = {
|
|
id: 'project-1',
|
|
name: 'Project',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
|
|
const conversations: Conversation[] = [
|
|
{ id: 'conv-a', projectId: project.id, title: 'A', createdAt: 1, updatedAt: 1 },
|
|
{ id: 'conv-b', projectId: project.id, title: 'B', createdAt: 1, updatedAt: 1 },
|
|
];
|
|
|
|
const createdConversation: Conversation = {
|
|
id: 'conv-c',
|
|
projectId: project.id,
|
|
title: null,
|
|
createdAt: 2,
|
|
updatedAt: 2,
|
|
};
|
|
|
|
const runningAssistant: ChatMessage = {
|
|
id: 'assistant-a',
|
|
role: 'assistant',
|
|
content: 'still running',
|
|
createdAt: 1,
|
|
runId: 'run-a',
|
|
runStatus: 'running',
|
|
};
|
|
|
|
const succeededAssistant: ChatMessage = {
|
|
...runningAssistant,
|
|
content: 'done',
|
|
runStatus: 'succeeded',
|
|
endedAt: 2,
|
|
};
|
|
|
|
describe('ProjectView conversation run isolation', () => {
|
|
let resolveConversationBMessages: ((messages: ChatMessage[]) => void) | null = null;
|
|
let conversationAMessages: ChatMessage[] = [runningAssistant];
|
|
|
|
beforeEach(() => {
|
|
resolveConversationBMessages = null;
|
|
conversationAMessages = [runningAssistant];
|
|
listConversations.mockResolvedValue(conversations);
|
|
listMessages.mockImplementation(async (_projectId: string, conversationId: string) => {
|
|
if (conversationId === 'conv-a') return conversationAMessages;
|
|
if (conversationId === 'conv-b') {
|
|
return new Promise<ChatMessage[]>((resolve) => {
|
|
resolveConversationBMessages = resolve;
|
|
});
|
|
}
|
|
return new Promise<ChatMessage[]>(() => {});
|
|
});
|
|
createConversation.mockResolvedValue(createdConversation);
|
|
fetchPreviewComments.mockResolvedValue([]);
|
|
loadTabs.mockResolvedValue({ tabs: [], active: null });
|
|
fetchProjectFiles.mockResolvedValue([]);
|
|
fetchLiveArtifacts.mockResolvedValue([]);
|
|
fetchSkill.mockResolvedValue(null);
|
|
fetchDesignSystem.mockResolvedValue(null);
|
|
getTemplate.mockResolvedValue(null);
|
|
listActiveChatRuns.mockResolvedValue([]);
|
|
listProjectRuns.mockResolvedValue([]);
|
|
fetchChatRunStatus.mockResolvedValue({
|
|
id: 'run-a',
|
|
status: 'running',
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
exitCode: null,
|
|
signal: null,
|
|
});
|
|
reattachDaemonRun.mockImplementation(async () => new Promise<void>(() => {}));
|
|
streamViaDaemon.mockImplementation(async () => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.unstubAllGlobals();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('allows sending in another conversation while the previous conversation has an active run', async () => {
|
|
renderProjectView();
|
|
|
|
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
|
|
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('streaming'));
|
|
|
|
fireEvent.click(screen.getByTestId('conversation-select-conv-b'));
|
|
|
|
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-b'));
|
|
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('idle'));
|
|
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', true);
|
|
|
|
fireEvent.click(screen.getByTestId('send-message'));
|
|
expect(streamViaDaemon).not.toHaveBeenCalled();
|
|
|
|
if (!resolveConversationBMessages) throw new Error('Expected conv-b message load to be pending');
|
|
resolveConversationBMessages([]);
|
|
|
|
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('idle'));
|
|
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false);
|
|
|
|
fireEvent.click(screen.getByTestId('send-message'));
|
|
|
|
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
|
|
expect(streamViaDaemon).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
projectId: 'project-1',
|
|
conversationId: 'conv-b',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not create duplicate empty conversations while a fresh conversation is loading', async () => {
|
|
renderProjectView();
|
|
|
|
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
|
|
|
|
fireEvent.click(screen.getByTestId('new-conversation'));
|
|
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-c'));
|
|
|
|
fireEvent.click(screen.getByTestId('new-conversation'));
|
|
|
|
expect(createConversation).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('blocks duplicate new conversations while creation is in flight', async () => {
|
|
let resolveCreate!: (conversation: Conversation) => void;
|
|
createConversation.mockImplementationOnce(
|
|
() => new Promise<Conversation>((resolve) => {
|
|
resolveCreate = resolve;
|
|
}),
|
|
);
|
|
|
|
renderProjectView();
|
|
|
|
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
|
|
|
|
fireEvent.click(screen.getByTestId('new-conversation'));
|
|
fireEvent.click(screen.getByTestId('new-conversation'));
|
|
|
|
expect(createConversation).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
resolveCreate(createdConversation);
|
|
});
|
|
});
|
|
|
|
it('notifies when a detached active run is terminal after returning to its conversation', async () => {
|
|
renderProjectView();
|
|
|
|
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
|
|
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('streaming'));
|
|
|
|
fireEvent.click(screen.getByTestId('conversation-select-conv-b'));
|
|
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-b'));
|
|
if (!resolveConversationBMessages) throw new Error('Expected conv-b message load to be pending');
|
|
resolveConversationBMessages([]);
|
|
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('idle'));
|
|
|
|
conversationAMessages = [succeededAssistant];
|
|
fireEvent.click(screen.getByTestId('conversation-select-conv-a'));
|
|
|
|
await waitFor(() => expect(playSound).toHaveBeenCalledWith('success-sound'));
|
|
expect(showCompletionNotification).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not reload or reattach when selecting the active streaming conversation', async () => {
|
|
renderProjectView();
|
|
|
|
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
|
|
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('streaming'));
|
|
|
|
listMessages.mockClear();
|
|
reattachDaemonRun.mockClear();
|
|
|
|
fireEvent.click(screen.getByTestId('conversation-select-conv-a'));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(screen.getByTestId('streaming-state').textContent).toBe('streaming');
|
|
expect(listMessages).not.toHaveBeenCalled();
|
|
expect(reattachDaemonRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps Stop hidden and Send disabled until active-run cancellation is attached', async () => {
|
|
fetchChatRunStatus.mockImplementation(async () => new Promise(() => {}));
|
|
|
|
renderProjectView();
|
|
|
|
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
|
|
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('idle'));
|
|
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', true);
|
|
|
|
fireEvent.click(screen.getByTestId('send-message'));
|
|
fireEvent.click(screen.getByTestId('workspace-send-comment'));
|
|
|
|
expect(streamViaDaemon).not.toHaveBeenCalled();
|
|
expect(reattachDaemonRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('surfaces conversation message load errors and keeps sends disabled until messages load', async () => {
|
|
let conversationBLoadAttempts = 0;
|
|
listMessages.mockImplementation(async (_projectId: string, conversationId: string) => {
|
|
if (conversationId === 'conv-a') return [];
|
|
if (conversationId === 'conv-b') {
|
|
conversationBLoadAttempts += 1;
|
|
if (conversationBLoadAttempts === 1) throw new Error('messages unavailable');
|
|
return [];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
renderProjectView();
|
|
|
|
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
|
|
fireEvent.click(screen.getByTestId('conversation-select-conv-b'));
|
|
|
|
await waitFor(() => expect(screen.getByTestId('chat-error').textContent).toBe('messages unavailable'));
|
|
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('idle'));
|
|
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', true);
|
|
expect(screen.getByTestId('workspace-streaming-state').textContent).toBe('streaming');
|
|
|
|
fireEvent.click(screen.getByTestId('send-message'));
|
|
|
|
expect(streamViaDaemon).not.toHaveBeenCalled();
|
|
|
|
fireEvent.click(screen.getByTestId('conversation-select-conv-b'));
|
|
|
|
await waitFor(() => expect(conversationBLoadAttempts).toBe(2));
|
|
await waitFor(() => expect(screen.getByTestId('chat-error').textContent).toBe(''));
|
|
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false);
|
|
});
|
|
|
|
it('does not rename an existing named project when sending the first message in an empty conversation', async () => {
|
|
const namedProject: Project = {
|
|
...project,
|
|
name: 'Imported Client Folder',
|
|
metadata: { kind: 'prototype', nameSource: 'user' },
|
|
};
|
|
const emptyConversation: Conversation = {
|
|
id: 'conv-empty',
|
|
projectId: namedProject.id,
|
|
title: null,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
listConversations.mockResolvedValue([emptyConversation]);
|
|
listMessages.mockResolvedValue([]);
|
|
fetchChatRunStatus.mockResolvedValue(null);
|
|
|
|
renderProjectView(config, namedProject);
|
|
|
|
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-empty'));
|
|
await waitFor(() => expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false));
|
|
|
|
fireEvent.click(screen.getByTestId('send-message'));
|
|
|
|
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
|
|
expect(patchProject).not.toHaveBeenCalledWith(
|
|
namedProject.id,
|
|
expect.objectContaining({ name: expect.any(String) }),
|
|
);
|
|
});
|
|
|
|
it('notifies when an API-mode chat completes without a daemon run status transition', async () => {
|
|
listMessages.mockResolvedValue([]);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
|
streamMessage.mockImplementation(
|
|
async (
|
|
_config: unknown,
|
|
_systemPrompt: unknown,
|
|
_history: unknown,
|
|
_signal: unknown,
|
|
handlers: { onDelta: (delta: string) => void; onDone: () => void },
|
|
) => {
|
|
handlers.onDelta('api response');
|
|
handlers.onDone();
|
|
},
|
|
);
|
|
|
|
renderProjectView({
|
|
...config,
|
|
mode: 'api',
|
|
apiKey: 'test-key',
|
|
model: 'api-model',
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
|
|
await waitFor(() => expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false));
|
|
|
|
fireEvent.click(screen.getByTestId('send-message'));
|
|
|
|
await waitFor(() => expect(streamMessage).toHaveBeenCalledTimes(1));
|
|
await waitFor(() => expect(playSound).toHaveBeenCalledWith('success-sound'));
|
|
});
|
|
});
|
|
|
|
function renderProjectView(renderConfig = config, renderProject: Project = project) {
|
|
return render(
|
|
<ProjectView
|
|
project={renderProject}
|
|
routeFileName={null}
|
|
config={renderConfig}
|
|
agents={[{ id: 'agent-1', name: 'OpenCode', bin: 'opencode', available: true, models: [] }]}
|
|
skills={[]}
|
|
designTemplates={[]}
|
|
designSystems={[]}
|
|
daemonLive
|
|
onModeChange={() => {}}
|
|
onAgentChange={() => {}}
|
|
onAgentModelChange={() => {}}
|
|
onRefreshAgents={() => {}}
|
|
onOpenSettings={() => {}}
|
|
onBack={() => {}}
|
|
onClearPendingPrompt={() => {}}
|
|
onTouchProject={() => {}}
|
|
onProjectChange={() => {}}
|
|
onProjectsRefresh={() => {}}
|
|
/>,
|
|
);
|
|
}
|