mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(web): queue chat sends * feat(web): render code comment directives * feat(web): add preview comments and manual edits * fix(web): polish shared chrome controls * fix(web): align queued send loading state * feat(web): open primary project artifacts * fix(web): keep queued sends and tests aligned * fix(web): restore docked comment tools layout * fix(web): align preview comment toolbar * fix(web): place local cli beside handoff * fix(web): move agent menu beside handoff * fix(web): make project instructions a direct header action * fix(web): compact handoff and toolbar labels * fix(web): clarify handoff menu and annotation label * fix(web): restore compact cursor handoff trigger * fix(web): align agent menu trigger with handoff * fix(web): add draw toolbar close action * fix(web): move inspect editing into edit mode * fix(web): avoid reserving comment sidebar in annotation mode * fix(web): float preview comments panel * fix(web): keep edit canvas full width * fix(web): polish preview annotation tools * fix(web): highlight active preview comments * fix(web): open comments panel after annotation save * fix(web): polish comment handoff controls * fix(web): remove palette preview tool * fix(web): simplify draw annotation toolbar * fix(web): restore queued tasks into composer * fix(web): restore queued send strip styling * fix(web): hide internal comment target ids * fix(web): align manual edit panel header * test(web): cover visual interaction contracts * fix(web): address PR feedback regressions * fix(web): preserve artifact chrome state * fix(daemon): restore project raw file routes --------- Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local> Co-authored-by: mrcfps <mrc@powerformer.com>
269 lines
8.3 KiB
TypeScript
269 lines
8.3 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||
import { useState } from 'react';
|
||
import type { ReactNode } from 'react';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import { ProjectView } from '../../src/components/ProjectView';
|
||
import type {
|
||
AgentInfo,
|
||
AppConfig,
|
||
Conversation,
|
||
DesignSystemSummary,
|
||
Project,
|
||
SkillSummary,
|
||
} from '../../src/types';
|
||
import {
|
||
createConversation,
|
||
listConversations,
|
||
listMessages,
|
||
loadTabs,
|
||
patchProject,
|
||
} 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(),
|
||
patchConversation: vi.fn(),
|
||
patchProject: vi.fn(),
|
||
saveMessage: vi.fn(),
|
||
saveTabs: vi.fn(),
|
||
};
|
||
});
|
||
|
||
vi.mock('../../src/components/AppChromeHeader', () => ({
|
||
AppChromeHeader: ({
|
||
children,
|
||
fileActionsBefore,
|
||
actions,
|
||
}: {
|
||
children: ReactNode;
|
||
fileActionsBefore?: ReactNode;
|
||
actions?: ReactNode;
|
||
}) => (
|
||
<header>
|
||
{children}
|
||
{fileActionsBefore}
|
||
{actions}
|
||
</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" />,
|
||
}));
|
||
|
||
vi.mock('../../src/components/ChatPane', () => ({
|
||
ChatPane: () => <div data-testid="chat-pane" />,
|
||
}));
|
||
|
||
const mockedListConversations = vi.mocked(listConversations);
|
||
const mockedCreateConversation = vi.mocked(createConversation);
|
||
const mockedListMessages = vi.mocked(listMessages);
|
||
const mockedLoadTabs = vi.mocked(loadTabs);
|
||
const mockedFetchPreviewComments = vi.mocked(fetchPreviewComments);
|
||
const mockedPatchProject = vi.mocked(patchProject);
|
||
|
||
const config: AppConfig = {
|
||
mode: 'api',
|
||
apiKey: '',
|
||
baseUrl: '',
|
||
model: '',
|
||
agentId: null,
|
||
skillId: null,
|
||
designSystemId: null,
|
||
};
|
||
|
||
const baseProject: Project = {
|
||
id: 'project-1',
|
||
name: 'Project 1',
|
||
skillId: null,
|
||
designSystemId: null,
|
||
createdAt: 1,
|
||
updatedAt: 1,
|
||
};
|
||
|
||
const conversation: Conversation = {
|
||
id: 'conv-1',
|
||
projectId: baseProject.id,
|
||
title: null,
|
||
createdAt: 1,
|
||
updatedAt: 1,
|
||
};
|
||
|
||
function ProjectViewHarness({ initialProject }: { initialProject: Project }) {
|
||
const [project, setProject] = useState(initialProject);
|
||
return (
|
||
<ProjectView
|
||
project={project}
|
||
routeFileName={null}
|
||
config={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={setProject}
|
||
onProjectsRefresh={vi.fn()}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const SAVED = 'Always use tabs, never spaces.';
|
||
|
||
async function openProjectInstructionsFromSettings() {
|
||
fireEvent.click(await screen.findByTestId('project-settings-trigger'));
|
||
}
|
||
|
||
describe('ProjectView – saved Project instructions surface (#1822)', () => {
|
||
beforeEach(() => {
|
||
mockedListConversations.mockResolvedValue([conversation]);
|
||
mockedCreateConversation.mockResolvedValue(conversation);
|
||
mockedListMessages.mockResolvedValue([]);
|
||
mockedLoadTabs.mockResolvedValue({ tabs: ['index.html'], active: 'index.html' });
|
||
mockedFetchPreviewComments.mockResolvedValue([]);
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('shows a persistent saved-state chip (not just a bare pencil) when instructions exist', async () => {
|
||
render(<ProjectViewHarness initialProject={{ ...baseProject, customInstructions: SAVED }} />);
|
||
|
||
const chip = await screen.findByTestId('project-instructions-chip');
|
||
expect(chip).toBeTruthy();
|
||
// The empty-state add affordance must not be the surface once a value exists.
|
||
expect(screen.queryByTestId('project-instructions-add')).toBeNull();
|
||
// Nothing is expanded until the user opts in.
|
||
expect(screen.queryByTestId('project-instructions-preview')).toBeNull();
|
||
expect(screen.queryByTestId('project-instructions-textarea')).toBeNull();
|
||
});
|
||
|
||
it('opens a read-only review panel that previews the saved instructions', async () => {
|
||
render(<ProjectViewHarness initialProject={{ ...baseProject, customInstructions: SAVED }} />);
|
||
|
||
fireEvent.click(await screen.findByTestId('project-instructions-chip'));
|
||
|
||
const preview = screen.getByTestId('project-instructions-preview');
|
||
expect(preview.textContent).toBe(SAVED);
|
||
// The panel makes the active/injected state explicit.
|
||
expect(screen.getByText('project.instructionsActive')).toBeTruthy();
|
||
// Review is read-only — no editor until the user asks to edit.
|
||
expect(screen.queryByTestId('project-instructions-textarea')).toBeNull();
|
||
});
|
||
|
||
it('reopens the editor from the review panel with the saved value prefilled', async () => {
|
||
render(<ProjectViewHarness initialProject={{ ...baseProject, customInstructions: SAVED }} />);
|
||
|
||
fireEvent.click(await screen.findByTestId('project-instructions-chip'));
|
||
fireEvent.click(screen.getByTestId('project-instructions-edit'));
|
||
|
||
const textarea = screen.getByTestId('project-instructions-textarea') as HTMLTextAreaElement;
|
||
expect(textarea.value).toBe(SAVED);
|
||
});
|
||
|
||
it('offers an add affordance and opens an empty editor when no instructions are saved', async () => {
|
||
render(<ProjectViewHarness initialProject={baseProject} />);
|
||
|
||
expect(await screen.findByTestId('project-settings-trigger')).toBeTruthy();
|
||
expect(screen.queryByTestId('project-instructions-chip')).toBeNull();
|
||
|
||
await openProjectInstructionsFromSettings();
|
||
|
||
const textarea = screen.getByTestId('project-instructions-textarea') as HTMLTextAreaElement;
|
||
expect(textarea.value).toBe('');
|
||
});
|
||
|
||
it('reads the saved value back in the review panel right after a save', async () => {
|
||
mockedPatchProject.mockResolvedValue({ ...baseProject, customInstructions: SAVED });
|
||
render(<ProjectViewHarness initialProject={baseProject} />);
|
||
|
||
await openProjectInstructionsFromSettings();
|
||
fireEvent.change(screen.getByTestId('project-instructions-textarea'), {
|
||
target: { value: SAVED },
|
||
});
|
||
fireEvent.click(screen.getByTestId('project-instructions-save'));
|
||
|
||
expect(mockedPatchProject).toHaveBeenCalledWith('project-1', { customInstructions: SAVED });
|
||
// Save lands on the review panel so the stored value is confirmed back.
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('project-instructions-preview').textContent).toBe(SAVED);
|
||
});
|
||
expect(screen.getByTestId('project-instructions-chip')).toBeTruthy();
|
||
});
|
||
});
|