open-design/apps/web/tests/components/ProjectView.resume-conversation.test.tsx
Eli-tangerine 8193981511
Keep PR 2400 changes without folder pickers (#2462)
* 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>
2026-05-20 22:07:30 +08:00

356 lines
10 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectView } from '../../src/components/ProjectView';
import type {
AgentInfo,
AppConfig,
ChatMessage,
Conversation,
DesignSystemSummary,
Project,
SkillSummary,
} from '../../src/types';
import {
createConversation,
listConversations,
listMessages,
synthesizeHandoff,
} from '../../src/state/projects';
import { fetchPreviewComments } from '../../src/providers/registry';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));
vi.mock('../../src/router', () => ({
navigate: vi.fn(),
}));
vi.mock('../../src/providers/anthropic', () => ({
streamMessage: vi.fn(),
}));
vi.mock('../../src/providers/daemon', () => ({
fetchChatRunStatus: vi.fn(),
listActiveChatRuns: vi.fn().mockResolvedValue([]),
listProjectRuns: vi.fn().mockResolvedValue([]),
reattachDaemonRun: vi.fn(),
streamViaDaemon: vi.fn(),
}));
vi.mock('../../src/providers/project-events', () => ({
useProjectFileEvents: vi.fn(),
}));
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
deletePreviewComment: vi.fn(),
fetchDesignSystem: vi.fn(),
fetchLiveArtifacts: vi.fn().mockResolvedValue([]),
fetchPreviewComments: vi.fn(),
fetchProjectFiles: vi.fn().mockResolvedValue([]),
fetchSkill: vi.fn(),
getTemplate: vi.fn(),
patchPreviewCommentStatus: vi.fn(),
upsertPreviewComment: vi.fn(),
writeProjectTextFile: vi.fn(),
};
});
vi.mock('../../src/state/projects', async () => {
const actual = await vi.importActual<typeof import('../../src/state/projects')>(
'../../src/state/projects',
);
return {
...actual,
createConversation: vi.fn(),
listConversations: vi.fn(),
listMessages: vi.fn(),
loadTabs: vi.fn().mockResolvedValue({ tabs: [], active: null }),
patchConversation: vi.fn(),
patchProject: vi.fn(),
saveMessage: vi.fn(),
saveTabs: vi.fn(),
synthesizeHandoff: vi.fn(),
};
});
vi.mock('../../src/components/AppChromeHeader', () => ({
AppChromeHeader: ({ children }: { children: React.ReactNode }) => <header>{children}</header>,
}));
vi.mock('../../src/components/AvatarMenu', () => ({ AvatarMenu: () => null }));
vi.mock('../../src/components/FileWorkspace', () => ({
FileWorkspace: () => <div data-testid="file-workspace" />,
}));
vi.mock('../../src/components/Loading', () => ({
CenteredLoader: () => <div data-testid="loader" />,
}));
// A thin ChatPane stand-in: exposes the resume control + the live message
// list + the composer draft so the test can prove the synthesized prompt
// is auto-sent (lands as a user message) rather than seeded into the draft.
vi.mock('../../src/components/ChatPane', () => ({
ChatPane: ({
messages,
onResumeConversation,
resumeConversationDisabled,
initialDraft,
}: {
messages: ChatMessage[];
onResumeConversation?: () => void;
resumeConversationDisabled?: boolean;
initialDraft?: string;
}) => (
<div>
<button
type="button"
data-testid="resume"
disabled={resumeConversationDisabled}
onClick={() => onResumeConversation?.()}
/>
<div data-testid="messages">
{messages.map((m) => `${m.role}:${m.content}`).join('|')}
</div>
<textarea data-testid="draft" readOnly value={initialDraft ?? ''} />
</div>
),
}));
const mockedListConversations = vi.mocked(listConversations);
const mockedCreateConversation = vi.mocked(createConversation);
const mockedListMessages = vi.mocked(listMessages);
const mockedSynthesizeHandoff = vi.mocked(synthesizeHandoff);
const mockedFetchPreviewComments = vi.mocked(fetchPreviewComments);
const config: AppConfig = {
mode: 'api',
apiKey: 'sk-test',
baseUrl: '',
model: 'claude-opus-4-7',
agentId: null,
skillId: null,
designSystemId: null,
};
const project: Project = {
id: 'p1',
name: 'Project p1',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 1,
};
const origConversation: Conversation = {
id: 'conv-orig',
projectId: 'p1',
title: 'Original',
createdAt: 1,
updatedAt: 1,
};
const freshConversation: Conversation = {
id: 'conv-new',
projectId: 'p1',
title: null,
createdAt: 2,
updatedAt: 2,
};
const origMessage: ChatMessage = {
id: 'm1',
role: 'user',
content: 'first turn',
createdAt: 1,
};
function renderProjectView(configOverride?: Partial<AppConfig>) {
return render(
<ProjectView
project={project}
routeFileName={null}
config={configOverride ? { ...config, ...configOverride } : config}
agents={[] as AgentInfo[]}
skills={[] as SkillSummary[]}
designTemplates={[] as SkillSummary[]}
designSystems={[] as DesignSystemSummary[]}
daemonLive
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={vi.fn()}
onRefreshAgents={vi.fn()}
onOpenSettings={vi.fn()}
onBack={vi.fn()}
onClearPendingPrompt={vi.fn()}
onTouchProject={vi.fn()}
onProjectChange={vi.fn()}
onProjectsRefresh={vi.fn()}
/>,
);
}
function messagesText(): string {
return screen.getByTestId('messages').textContent ?? '';
}
describe('ProjectView resume conversation', () => {
beforeEach(() => {
mockedListConversations.mockResolvedValue([origConversation]);
mockedCreateConversation.mockResolvedValue(freshConversation);
// The original conversation carries a transcript; the freshly created
// one is empty (its DB read settles before the auto-send fires).
mockedListMessages.mockImplementation(async (_projectId, conversationId) =>
conversationId === origConversation.id ? [origMessage] : [],
);
mockedFetchPreviewComments.mockResolvedValue([]);
// handleSend's best-effort memory/extract POST hits fetch; keep it benign.
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('{}', { status: 200 })),
);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it('synthesizes a handoff prompt and auto-sends it as the first message of a new conversation', async () => {
mockedSynthesizeHandoff.mockResolvedValue({
prompt: 'SYNTHESIZED HANDOFF',
model: 'claude-opus-4-7',
inputTokens: 10,
outputTokens: 5,
transcriptMessageCount: 1,
});
renderProjectView();
// Wait for the original transcript to hydrate so the resume control
// is enabled (it is disabled when there is nothing to hand off).
await waitFor(() => {
expect(messagesText()).toContain('user:first turn');
});
expect((screen.getByTestId('resume') as HTMLButtonElement).disabled).toBe(false);
screen.getByTestId('resume').click();
await waitFor(() => {
expect(mockedSynthesizeHandoff).toHaveBeenCalledWith('p1', {
// Scoped to the conversation being resumed, not the whole project.
conversationId: origConversation.id,
apiKey: 'sk-test',
model: 'claude-opus-4-7',
maxTokens: expect.any(Number),
});
});
// Default Anthropic config has baseUrl '' — it must be omitted, not
// forwarded as an empty string the handoff route would 400.
expect(mockedSynthesizeHandoff.mock.calls[0]![1]).not.toHaveProperty('baseUrl');
await waitFor(() => {
expect(mockedCreateConversation).toHaveBeenCalledWith('p1');
});
// The synthesized prompt must land as a real user message in the new
// conversation — proving auto-send, not a composer seed.
await waitFor(() => {
expect(messagesText()).toContain('user:SYNTHESIZED HANDOFF');
});
expect(messagesText()).not.toContain('user:first turn');
expect((screen.getByTestId('draft') as HTMLTextAreaElement).value).toBe('');
});
it('forwards baseUrl when the user has set a custom one', async () => {
mockedSynthesizeHandoff.mockResolvedValue({
prompt: 'SYNTHESIZED HANDOFF',
model: 'claude-opus-4-7',
inputTokens: 10,
outputTokens: 5,
transcriptMessageCount: 1,
});
renderProjectView({ baseUrl: 'https://proxy.example' });
await waitFor(() => {
expect(messagesText()).toContain('user:first turn');
});
screen.getByTestId('resume').click();
await waitFor(() => {
expect(mockedSynthesizeHandoff).toHaveBeenCalledWith(
'p1',
expect.objectContaining({ baseUrl: 'https://proxy.example' }),
);
});
});
it('disables the resume control while the conversation has no transcript to hand off', async () => {
// Guards the `messages.length === 0` arm of resumeConversationDisabled:
// a fresh/empty conversation has nothing to synthesize a handoff from.
mockedListMessages.mockResolvedValue([]);
renderProjectView();
await waitFor(() => {
expect(screen.getByTestId('resume')).toBeTruthy();
});
expect((screen.getByTestId('resume') as HTMLButtonElement).disabled).toBe(true);
expect(messagesText()).toBe('');
});
it('shows a toast and creates no conversation when synthesis fails', async () => {
mockedSynthesizeHandoff.mockResolvedValue(null);
renderProjectView();
await waitFor(() => {
expect(messagesText()).toContain('user:first turn');
});
screen.getByTestId('resume').click();
await waitFor(() => {
expect(mockedSynthesizeHandoff).toHaveBeenCalledTimes(1);
});
await screen.findByText(/handoff prompt/i);
expect(mockedCreateConversation).not.toHaveBeenCalled();
expect(messagesText()).toContain('user:first turn');
});
it('surfaces the daemon-classified error message in the toast', async () => {
// A structured daemon error (rate limit, empty transcript, ...) must
// reach the toast verbatim, not collapse into a generic message.
mockedSynthesizeHandoff.mockResolvedValue({
error: {
code: 'RATE_LIMITED',
message: 'This request would exceed your rate limit of 30,000 input tokens per minute.',
},
});
renderProjectView();
await waitFor(() => {
expect(messagesText()).toContain('user:first turn');
});
screen.getByTestId('resume').click();
await screen.findByText(/exceed your rate limit/i);
expect(mockedCreateConversation).not.toHaveBeenCalled();
});
});