feat: 改造云智能体桌面编辑与试用工作区

This commit is contained in:
2026-09-12 17:39:31 +08:00
parent 6b92591129
commit dca6deab29
16 changed files with 623 additions and 222 deletions

View File

@@ -26,8 +26,13 @@ beforeEach(() => {
api.recovery.mockResolvedValue({ recent: null, pending: [] });
api.remember.mockResolvedValue({ saved: true });
api.list.mockResolvedValue({ agents: [draft], next_cursor: null });
api.call.mockImplementation(async (operation: string) => operation === 'knowledge'
? { databases: [], models: [] } : { models: [], resources: {}, pricing: null });
api.call.mockImplementation(async (operation: string) => {
if (operation === 'knowledge') return { databases: [], models: [] };
if (operation === 'resources') return { mcps: [], skills: [], subagents: [] };
if (operation === 'threads') return { threads: [], next_offset: null };
if (operation === 'budget') return { request_limit_points: null, daily_limit_points: null, daily_committed_points: '0.00' };
return { models: [], resources: {}, pricing: null };
});
});
afterEach(cleanup);
@@ -45,6 +50,7 @@ it('restores the exact previous thread and presents an uncertain operation witho
});
open();
await screen.findByText('恢复了旧对话');
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
expect(screen.getByLabelText('历史对话')).toHaveValue('older-thread');
expect(api.resolvePending).not.toHaveBeenCalled();
expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false);
@@ -162,11 +168,84 @@ it('keeps unsent preview text when configuration changes and waits before switch
fireEvent.change(prompt, { target: { value: '还未发送的问题' } });
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的名称' } });
expect(prompt).toHaveValue('还未发送的问题');
expect(screen.getByText(/预览仍使用已保存的修订 1/)).toBeVisible();
expect(screen.getByRole('button', { name: '保存草稿' })).toBeDisabled();
fireEvent.change(prompt, { target: { value: '' } });
expect(screen.getByText(/保存不会清空当前试用/)).toBeVisible();
await waitFor(() => expect(screen.getByRole('button', { name: '保存草稿' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByRole('button', { name: '已保存' });
expect(api.save).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('消息')).toBe(prompt);
expect(prompt).toHaveValue('还未发送的问题');
expect(screen.getByText('草稿预览 · 修订 1')).toBeVisible();
expect(screen.getByRole('button', { name: '发送', exact: true })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '用最新草稿试用' }));
expect(screen.getByText('草稿预览 · 修订 2')).toBeVisible();
expect(prompt).toHaveValue('还未发送的问题');
expect(screen.getByRole('button', { name: '发送', exact: true })).toBeEnabled();
});
it('keeps preview text across mode switches and saves a budget independently of the draft', async () => {
const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } };
api.list.mockResolvedValue({ agents: [configured], next_cursor: null });
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => operation === 'saveBudget' ? { ...input, daily_committed_points: '0.00' } : base(operation, input));
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '待发送的问题' } });
fireEvent.change(screen.getByLabelText('角色与指令'), { target: { value: '未保存的指令' } });
fireEvent.click(screen.getByRole('button', { name: '对话', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '编辑', exact: true }));
expect(screen.getByLabelText('消息')).toHaveValue('待发送的问题');
expect(screen.getByLabelText('角色与指令')).toHaveValue('未保存的指令');
fireEvent.click(screen.getByRole('button', { name: '限制', exact: true }));
fireEvent.change(screen.getByLabelText('每日上限(词元点数)'), { target: { value: '12' } });
fireEvent.click(screen.getByRole('button', { name: '保存费用上限' }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith('saveBudget', expect.objectContaining({ daily_limit_points: '12' })));
expect(api.save).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '指令', exact: true }));
expect(screen.getByLabelText('角色与指令')).toHaveValue('未保存的指令');
expect(screen.getByLabelText('消息')).toHaveValue('待发送的问题');
});
it('keeps an existing preview when the saved draft clears its model selection', async () => {
const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } };
api.list.mockResolvedValue({ agents: [configured], next_cursor: null });
api.save.mockResolvedValue({ ...draft, draft_revision: 2 });
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
const prompt = screen.getByLabelText('消息');
fireEvent.change(prompt, { target: { value: '还没有发送' } });
fireEvent.click(screen.getByRole('button', { name: '能力', exact: true }));
fireEvent.change(screen.getByLabelText('模型'), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByText('云端已保存');
expect(screen.getByLabelText('消息')).toBe(prompt);
expect(prompt).toHaveValue('还没有发送');
expect(screen.getByRole('button', { name: '用最新草稿试用' })).toBeDisabled();
});
it('searches capabilities while retaining existing selections outside the results', async () => {
const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model', tools: ['missing-tool'] } };
api.list.mockResolvedValue({ agents: [configured], next_cursor: null });
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => operation === 'catalog' ? {
models: [{ id: 'test-model', name: '写作模型' }], resources: { tools: [{ key: 'search', name: '资料搜索', description: '搜索素材' }, { key: 'clock', name: '当前时间' }] }, pricing: null,
} : base(operation, input));
api.save.mockImplementation(async (_slug, input) => ({ ...configured, ...input, draft_revision: 2 }));
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
fireEvent.click(screen.getByRole('button', { name: '能力', exact: true }));
expect(await screen.findByRole('checkbox', { name: /missing-tool/ })).toBeChecked();
fireEvent.click(screen.getByRole('button', { name: '选择工具', exact: true }));
fireEvent.change(screen.getByLabelText('搜索工具'), { target: { value: '资料' } });
expect(screen.queryByRole('checkbox', { name: '当前时间' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: /资料搜索/ }));
fireEvent.click(screen.getByRole('button', { name: '完成选择' }));
expect(screen.getByRole('checkbox', { name: /missing-tool/ })).toBeChecked();
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await waitFor(() => expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ configuration: expect.objectContaining({ tools: ['missing-tool', 'search'] }) })));
});

View File

@@ -63,11 +63,13 @@ it('returns to the last selected thread on remount and requires restoring an arc
return base(operation, input);
});
const first = render(<CloudChat slug={slug} />);
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
await waitFor(() => expect(screen.getByLabelText('历史对话')).toHaveValue('older'));
fireEvent.change(screen.getByLabelText('历史对话'), { target: { value: 'newest' } });
await waitFor(() => expect(recent.thread_id).toBe('newest'));
first.unmount();
const second = render(<CloudChat slug={slug} />);
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
await waitFor(() => expect(screen.getByLabelText('历史对话')).toHaveValue('newest'));
second.unmount();
recent = { ...recent, thread_id: 'older' };
@@ -241,6 +243,7 @@ it('returns to a newly created conversation from history without remounting the
await waitFor(() => expect(screen.getByLabelText('消息')).not.toBeDisabled());
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '新对话主题' } });
fireEvent.click(screen.getByRole('button', { name: '发送', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
await waitFor(() => expect(screen.getByLabelText('历史对话')).toBeEnabled());
expect(screen.getByRole('option', { name: '新对话主题 · 已完成' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '新对话' }));
@@ -280,3 +283,69 @@ it('creates an attachment conversation only when the selected file is confirmed'
expect(api.call.mock.calls.filter(call => call[0] === 'createThread')).toHaveLength(1);
await waitFor(() => expect(screen.getByText('附件与产物 · 本条消息附带 1 个文件')).toBeVisible());
});
it('pins uncertain preview retries to the original request and revision after a draft save', async () => {
const base = api.call.getMockImplementation()!;
let attempts = 0;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'preview') {
if (++attempts === 1) throw new Error('响应丢失');
return { request_id: input.request_id, thread_id: 'thread-1', run_id: 'run-1', status: 'completed', version: 'draft:1' };
}
if (operation === 'history') return { ...history, run: { ...run, status: 'completed', version: 'draft:1' }, messages: [{ id: 1, role: 'assistant', content: '第一版回答' }] };
return base(operation, input);
});
const view = render(<CloudChat slug={slug} previewRevision={1} />);
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '原来的问题' } });
fireEvent.click(screen.getByRole('button', { name: '发送', exact: true }));
await screen.findByRole('button', { name: '重试本次发送' });
view.rerender(<CloudChat slug={slug} previewRevision={2} />);
expect(screen.getByRole('button', { name: '用最新草稿试用' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '重试本次发送' }));
await screen.findByText('第一版回答');
const submissions = api.call.mock.calls.filter(call => call[0] === 'preview');
expect(submissions).toHaveLength(2);
expect(submissions[0][1]).toEqual(submissions[1][1]);
expect(submissions[1][1]).toMatchObject({ expected_revision: 1, query: '原来的问题' });
fireEvent.click(screen.getByRole('button', { name: '用最新草稿试用' }));
expect(screen.queryByText('第一版回答')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
fireEvent.change(screen.getByLabelText('历史对话'), { target: { value: 'thread-1' } });
await screen.findByText('第一版回答');
expect(screen.getByText('草稿预览 · 修订 1')).toBeVisible();
});
it('uses Enter to send while preserving Shift+Enter and Chinese IME composition', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => operation === 'preview'
? { request_id: input.request_id, thread_id: 'thread-1', status: 'completed' } : base(operation, input));
render(<CloudChat slug={slug} previewRevision={3} />);
const input = screen.getByLabelText('消息');
await waitFor(() => expect(input).toBeEnabled());
fireEvent.change(input, { target: { value: '整理我的思路' } });
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 });
expect(api.call.mock.calls.some(call => call[0] === 'preview')).toBe(false);
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => expect(api.call).toHaveBeenCalledWith('preview', expect.objectContaining({ expected_revision: 3, query: '整理我的思路' })));
});
it('does not mark a retained hidden conversation as read until it is shown', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'threads') return { threads: [{ thread_id: 'thread-1', mode: 'published' }], next_offset: null };
if (operation === 'history') return { ...history, run: { ...run, status: 'completed' }, messages: [{ id: 1, role: 'assistant', content: '后台的新回答' }] };
if (operation === 'viewed') return { viewed: true };
return base(operation, input);
});
const view = render(<CloudChat slug={slug} activeView={false} />);
await screen.findByText('后台的新回答');
expect(api.call.mock.calls.some(call => call[0] === 'viewed')).toBe(false);
expect(api.remember).not.toHaveBeenCalled();
view.rerender(<CloudChat slug={slug} activeView />);
await waitFor(() => expect(api.call).toHaveBeenCalledWith('viewed', { thread_id: 'thread-1', run_id: 'run-1' }));
});

View File

@@ -89,7 +89,16 @@ describe('MainLayout module isolation', () => {
expect(screen.getByTestId('sidebar-stub')).toHaveAttribute('data-has-peek-handler', 'false');
});
it('does not overlay the local project initialization gate on AI hardware', () => {
it('gives cloud Agents the full desktop content area and an account workspace title', () => {
renderLayout('/cloud-agents');
expect(screen.getByTestId('main-content')).toHaveClass('h-full', 'overflow-hidden', 'p-0');
expect(screen.getByTestId('main-content')).not.toHaveClass('p-5', 'sm:p-6');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-page-title', '智能体工作区');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
expect(screen.getByTestId('sidebar-stub')).toHaveAttribute('data-hide-on-compact', 'false');
});
it('does not overlay the local project initialization gate on AI hardware' , () => {
renderLayout('/ai-hardware');
expect(screen.getByTestId('route-content')).toBeVisible();

View File

@@ -52,7 +52,7 @@ describe('AI module navigation', () => {
});
});
it('shows only the three supported module choices and routes Canvas from the chooser', async () => {
it('shows the four supported module choices and routes Canvas from the chooser', async () => {
render(
<MemoryRouter initialEntries={['/module-select']}>
<Routes>
@@ -68,10 +68,10 @@ describe('AI module navigation', () => {
expect(screen.getByTestId('ai-module-options')).toHaveClass('module-selection-options');
expect(await screen.findByText('小明,欢迎回来!')).toBeInTheDocument();
expect(screen.getByTestId('ai-module-option-programming')).toHaveClass('module-option-card', 'module-option-card-horizontal');
for (const moduleId of ['programming', 'painting', 'robot'] as const) {
for (const moduleId of ['programming', 'painting', 'robot', 'cloud_agents'] as const) {
expect(screen.getByTestId(`ai-module-option-${moduleId}`).querySelector('.module-option-artwork')).toBeNull();
}
expect(screen.getAllByTestId(/^ai-module-option-/u)).toHaveLength(3);
expect(screen.getAllByTestId(/^ai-module-option-/u)).toHaveLength(4);
expect(screen.queryByTestId('ai-module-option-learning')).not.toBeInTheDocument();
expect(screen.queryByText('先选一个入口。进入后,你还可以从左下角随时切换到其他模块。')).not.toBeInTheDocument();
expect(screen.queryByText('选择创作空间。你可以随时从侧边栏切换。')).not.toBeInTheDocument();