fix(agents): open cards from home and align sidebar navigation

This commit is contained in:
鲨鱼辣椒 committed 2026-10-12 20:06:22 +08:00
1 parent e5d79bf37a
commit 5e99804bc2
13 files changed
+460 -27

No files matched your search

@@ -0,0 +1,123 @@
import { test, expect, type Page } from '@playwright/test';
import { createServer, type ViteDevServer } from 'vite';
import { resolve } from 'node:path';
let server: ViteDevServer;
let baseUrl: string;
test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
// Exercise actual navigation controls and persisted layout state without
// starting Electron or connecting to an account or any cloud service.
test.beforeAll(async () => {
const entry = resolve('tests/e2e/__cloud-agent-navigation-layout-harness.tsx');
server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite-cloud-agent-navigation-layout',
resolve: { alias: { '@': resolve('src') } },
esbuild: { jsx: 'automatic' },
server: { host: '127.0.0.1', port: 0 },
plugins: [{
name: 'isolated-cloud-agent-navigation-layout', enforce: 'pre',
resolveId(id) { if (id === '/__cloud-agent-navigation-layout-harness.tsx') return entry; },
load(id) {
if (id === entry) return `
import { createRoot } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { TitleBar } from '/src/components/layout/TitleBar.tsx';
import { useSettingsStore } from '/src/stores/settings.ts';
import '/src/styles/globals.css';
function AgentFrame() {
const collapsed = useSettingsStore(state => state.sidebarCollapsed);
const location = useLocation();
return <div className="flex h-screen min-w-0 flex-col">
<TitleBar integrated showModuleReturn pageTitle="我的智能体" />
<div className="flex min-h-0 flex-1">
{!collapsed && <aside aria-label="智能体侧栏" className="w-64 shrink-0" />}
<main className="min-w-0 flex-1"><output data-testid="fixture-location">{location.pathname}</output></main>
</div>
</div>;
}
createRoot(document.getElementById('root')).render(<MemoryRouter initialEntries={['/cloud-agents?view=mine']}>
<Routes><Route path="/cloud-agents" element={<AgentFrame />} /><Route path="/module-select" element={<main>首页模块卡片</main>} /></Routes>
</MemoryRouter>);
`;
if (id.endsWith('/src/stores/coding-workspace.ts')) return 'export function useCodingWorkspaceStore(select) { return select({ activeProject: null }); }';
if (id.endsWith('/src/stores/user-sync.ts')) return 'export const useUserSyncStore = { getState: () => ({ pushPreferences: async () => undefined }) };';
},
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url !== '/' && !req.url?.startsWith('/?')) return next();
res.setHeader('Content-Type', 'text/html');
res.end('<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/__cloud-agent-navigation-layout-harness.tsx"></script></body></html>');
});
},
}],
});
await server.listen();
baseUrl = server.resolvedUrls!.local[0];
});
test.afterAll(async () => { await server?.close(); });
async function expectNavigationRow(page: Page, width: number) {
const back = page.getByRole('button', { name: '返回首页', exact: true });
const toggle = page.getByTestId('titlebar-sidebar-toggle');
await expect(back).toHaveCount(1);
await expect(back).toBeVisible();
await expect(toggle).toBeVisible();
const a = (await back.boundingBox())!;
const b = (await toggle.boundingBox())!;
expect(Math.abs((a.y + a.height / 2) - (b.y + b.height / 2))).toBeLessThanOrEqual(1);
expect(a.x + a.width).toBeLessThanOrEqual(b.x);
for (const box of [a, b]) {
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(width);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.width).toBeGreaterThanOrEqual(32);
expect(box.height).toBeGreaterThanOrEqual(32);
}
return b;
}
for (const platform of ['darwin', 'win32', 'linux']) {
for (const width of [375, 1024]) {
test(`${platform} Agent return and sidebar toggle share one usable row at ${width}px`, async ({ page }) => {
const pageErrors: string[] = [];
page.on('pageerror', error => pageErrors.push(error.message));
await page.setViewportSize({ width, height: 760 });
await page.addInitScript(platform => {
Object.defineProperty(window, 'electron', { value: { platform, ipcRenderer: {
invoke: async (channel: string) => {
if (channel === 'window:isMaximized' || channel === 'window:isFullScreen') return false;
throw new Error('Unexpected fixture IPC ' + channel);
},
on: () => () => {},
} } });
}, platform);
await page.goto(baseUrl);
await expect(page.getByTestId('fixture-location')).toHaveText('/cloud-agents');
const expandedToggle = await expectNavigationRow(page, width);
await expect(page.getByTestId('titlebar-sidebar-toggle')).toHaveAttribute('aria-label', '折叠侧栏');
// 375px stresses application controls; Windows' native desktop frame has
// a larger minimum width, so validate its chrome separation at 1024px.
if (platform !== 'win32' || width === 1024) {
const logo = (await page.getByTestId('titlebar-logo').boundingBox())!;
expect(expandedToggle.x + expandedToggle.width).toBeLessThanOrEqual(logo.x);
}
await page.getByTestId('titlebar-sidebar-toggle').click();
await expect(page.getByRole('complementary', { name: '智能体侧栏' })).toHaveCount(0);
await expect(page.getByTestId('titlebar-sidebar-toggle')).toHaveAttribute('aria-label', '展开侧栏');
const collapsedToggle = await expectNavigationRow(page, width);
const collapsedLogo = (await page.getByTestId('titlebar-logo').boundingBox())!;
expect(collapsedToggle.x + collapsedToggle.width).toBeLessThanOrEqual(collapsedLogo.x);
await page.getByTestId('titlebar-sidebar-toggle').click();
await expect(page.getByRole('complementary', { name: '智能体侧栏' })).toBeVisible();
await expectNavigationRow(page, width);
await page.getByTestId('titlebar-sidebar-toggle').click();
await page.getByRole('button', { name: '返回首页', exact: true }).click();
await expect(page.getByText('首页模块卡片')).toBeVisible();
expect(pageErrors).toEqual([]);
});
}
}
+26
View File
@@ -116,6 +116,20 @@ test('personal cloud Agent identity, configuration and separate published chat i
await page.getByTestId('ai-module-option-cloud_agents').click();
await expect(page.getByTestId('sidebar-cloud-agents-navigation')).toBeVisible();
await expect(page.getByTestId('sidebar-robot-navigation')).toHaveCount(0);
const navigation = page.getByTestId('agent-titlebar-navigation');
await expect(navigation.getByRole('button', { name: '返回首页', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '返回首页', exact: true })).toHaveCount(1);
const returnBounds = await navigation.getByRole('button', { name: '返回首页', exact: true }).boundingBox();
const collapseBounds = await navigation.getByRole('button', { name: '折叠侧栏', exact: true }).boundingBox();
expect(returnBounds).not.toBeNull();
expect(collapseBounds).not.toBeNull();
expect(Math.abs(returnBounds!.y - collapseBounds!.y)).toBeLessThanOrEqual(1);
expect(returnBounds!.x + returnBounds!.width).toBeLessThan(collapseBounds!.x);
await navigation.getByRole('button', { name: '折叠侧栏', exact: true }).click();
await page.mouse.move(700, 400);
await expect(navigation.getByRole('button', { name: '返回首页', exact: true })).toBeVisible();
await navigation.getByRole('button', { name: /展开侧栏|固定侧栏/ }).click();
await expect(page.getByTestId('sidebar-cloud-agents-navigation')).toBeVisible();
const fixtureState = () => app.evaluate(() => Reflect.get(globalThis, '__cloudAgentUiFixture').snapshot());
const messageOperations = async () => (await fixtureState()).operations.filter((operation: string) => operation === 'preview' || operation === 'submit');
await page.getByRole('button', { name: '创建我的第一个智能体', exact: true }).click();
@@ -249,6 +263,18 @@ test('personal cloud Agent identity, configuration and separate published chat i
await expect(page.getByRole('button', { name: /^(聊天|自动任务|使用与接入)$/ })).toHaveCount(0);
expect(await messageOperations()).toEqual(['submit']);
await page.screenshot({ path: testInfo.outputPath('cloud-agent-separate-chat.png') });
const operationsBeforeReentry = (await fixtureState()).operations;
await page.getByRole('button', { name: '返回首页', exact: true }).click();
await page.getByTestId('ai-module-option-cloud_agents').click();
await expect(page.getByRole('article', { name: '我的新版写作搭档', exact: true })).toBeVisible();
await expect(editor).toHaveCount(0);
await expect(formalChat).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath('cloud-agent-home-entry-navigation.png') });
await page.getByRole('article', { name: '我的新版写作搭档', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click();
await expect(formalChat).toBeVisible();
expect(await messageOperations()).toEqual(['submit']);
const operationsAfterReentry = (await fixtureState()).operations;
expect(operationsAfterReentry.filter((operation: string) => operation === 'publish')).toEqual(operationsBeforeReentry.filter((operation: string) => operation === 'publish'));
} finally {
await app.evaluate(({ BrowserWindow }) => { for (const win of BrowserWindow.getAllWindows()) win.destroy(); }).catch(() => undefined);
await closeElectronApp(app);
@@ -0,0 +1,125 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { ModuleSelection } from '@/pages/ModuleSelection';
import CloudAgents from '@/pages/CloudAgents';
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudRecent } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), uploadAvatar: vi.fn(), get: vi.fn(), save: vi.fn(), call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), resolvePending: vi.fn() }));
const auth = vi.hoisted(() => ({
user: { userId: 'creator', username: 'creator' },
moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true },
isAuthenticated: () => true,
}));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: typeof auth) => unknown) => selector(auth) }));
vi.mock('@/hooks/use-current-user-profile', () => ({ useCurrentUserProfile: () => ({ userProfile: undefined, profileRequired: false, profileSyncError: null }) }));
vi.mock('@/components/profile/UserProfileDialog', () => ({ UserProfileDialog: () => null }));
vi.mock('@/pages/CloudAgents/DraftEditor', () => ({
DraftEditor: ({ initial, entryView, recent }: { initial: CloudAgentDraft; entryView?: string; recent: CloudRecent | null }) =>
<div data-testid="entry-editor" data-entry-view={entryView ?? 'restored'} data-thread={recent?.thread_id}>{initial.name}</div>,
}));
vi.mock('@/pages/CloudAgents/CloudChat', () => ({ CloudChat: ({ slug }: { slug: string }) => <div data-testid="entry-chat">{slug}</div> }));
vi.mock('@/pages/CloudAgents/ChannelAccounts', () => ({ ChannelAccounts: () => <div>微信渠道页面</div> }));
const draft: CloudAgentDraft = {
slug: 'ml-' + 'a'.repeat(32), name: '学习朋友', purpose: '一起探索新知识', system_prompt: '回答问题',
draft_revision: 4, updated_at: '2026-10-12T00:00:00Z', configuration: EMPTY_CLOUD_CONFIGURATION,
published_version: 2, enabled: true,
};
const recent: CloudRecent = { slug: draft.slug, thread_id: 'recent-thread', mode: 'published' };
const pending = { id: 'pending-message', operation: 'submit', input: { query: '上次的问题' }, created_at: '2026-10-12T00:00:00Z' };
function open(entry = '/module-select') {
const router = createMemoryRouter([
{ path: '/module-select', element: <ModuleSelection /> },
{ path: '/cloud-agents/*', element: <CloudAgents /> },
], { initialEntries: [entry] });
render(<RouterProvider router={router} />);
return router;
}
beforeEach(() => {
vi.resetAllMocks();
api.list.mockResolvedValue({ agents: [draft], next_cursor: null });
api.get.mockResolvedValue(draft);
api.recovery.mockResolvedValue({ recent, pending: [pending] });
api.remember.mockResolvedValue({ saved: true });
});
afterEach(cleanup);
it('opens the card list from the home Agents choice without changing the remembered workspace', async () => {
const router = open();
fireEvent.click(screen.getByTestId('ai-module-option-cloud_agents'));
expect(await screen.findByRole('article', { name: draft.name })).toBeVisible();
expect(await screen.findByRole('complementary', { name: '未确认的云端操作' })).toHaveTextContent('上次的问题');
expect(router.state.location.pathname + router.state.location.search).toBe('/cloud-agents?view=mine');
expect(screen.queryByTestId('entry-editor')).not.toBeInTheDocument();
expect(api.get).not.toHaveBeenCalled();
expect(api.remember).not.toHaveBeenCalled();
expect(api.resolvePending).not.toHaveBeenCalled();
});
it('opens an available Agent conversation only after its card is selected', async () => {
open();
fireEvent.click(screen.getByTestId('ai-module-option-cloud_agents'));
fireEvent.click(await screen.findByRole('button', { name: '和它聊聊', exact: true }));
expect(await screen.findByTestId('entry-editor')).toHaveAttribute('data-entry-view', 'chat');
expect(screen.getByTestId('entry-editor')).not.toHaveAttribute('data-thread');
expect(api.call).not.toHaveBeenCalled();
});
it('does not replace an explicitly opened draft when recovery arrives late', async () => {
let resolveRecovery!: (value: { recent: CloudRecent; pending: typeof pending[] }) => void;
api.recovery.mockReturnValue(new Promise(resolve => { resolveRecovery = resolve; }));
api.list.mockResolvedValue({ agents: [{ ...draft, published_version: null }], next_cursor: null });
open();
fireEvent.click(screen.getByTestId('ai-module-option-cloud_agents'));
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
expect(screen.getByTestId('entry-editor')).toHaveAttribute('data-entry-view', 'configuration');
await act(async () => resolveRecovery({ recent: { ...recent, slug: 'ml-' + 'b'.repeat(32) }, pending: [pending] }));
expect(screen.getByTestId('entry-editor')).toHaveTextContent(draft.name);
expect(screen.getByTestId('entry-editor')).toHaveAttribute('data-entry-view', 'configuration');
expect(api.get).not.toHaveBeenCalled();
expect(api.remember).not.toHaveBeenCalled();
});
it('preserves remembered conversation recovery for a direct workspace entry', async () => {
open('/cloud-agents');
expect(await screen.findByTestId('entry-editor')).toHaveAttribute('data-thread', 'recent-thread');
expect(api.get).toHaveBeenCalledWith(draft.slug);
expect(api.remember).not.toHaveBeenCalled();
});
it('returns from a remembered workspace to the explicit card list without erasing history', async () => {
const router = open('/cloud-agents');
await screen.findByTestId('entry-editor');
api.get.mockClear();
await act(async () => { await router.navigate('/cloud-agents?view=mine'); });
expect(await screen.findByRole('article', { name: draft.name })).toBeVisible();
await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2));
expect(screen.queryByTestId('entry-editor')).not.toBeInTheDocument();
expect(api.get).not.toHaveBeenCalled();
expect(api.remember).not.toHaveBeenCalled();
});
it('preserves explicit shared Agent links instead of reopening the remembered owner draft', async () => {
const sharedSlug = 'ml-' + 'b'.repeat(32);
api.call.mockResolvedValue({ slug: sharedSlug, name: '分享朋友', purpose: '', available: true });
open(`/cloud-agents/shared/${sharedSlug}`);
expect(await screen.findByTestId('entry-chat')).toHaveTextContent(sharedSlug);
expect(api.call).toHaveBeenCalledWith('entry', { slug: sharedSlug });
expect(api.get).not.toHaveBeenCalled();
expect(api.remember).not.toHaveBeenCalled();
});
it('preserves explicit channel links', async () => {
open('/cloud-agents/channels');
expect(screen.getByText('微信渠道页面')).toBeVisible();
expect(api.recovery).not.toHaveBeenCalled();
expect(api.list).not.toHaveBeenCalled();
});
+6 -2
View File
@@ -23,11 +23,12 @@ vi.mock('@/components/layout/Sidebar', () => ({
),
}));
vi.mock('@/components/layout/TitleBar', () => ({
TitleBar: ({ integrated = false, overlay = false, showSidebarControls = true, pageTitle, sidebarPeekOpen = false, onSidebarPeekChange }: {
TitleBar: ({ integrated = false, overlay = false, showSidebarControls = true, pageTitle, showModuleReturn = false, sidebarPeekOpen = false, onSidebarPeekChange }: {
integrated?: boolean;
overlay?: boolean;
showSidebarControls?: boolean;
pageTitle?: string;
showModuleReturn?: boolean;
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: (open: boolean, source: 'toggle' | 'focus' | 'sidebar' | 'titlebar') => void;
}) => (
@@ -37,6 +38,7 @@ vi.mock('@/components/layout/TitleBar', () => ({
data-overlay={overlay ? 'true' : 'false'}
data-sidebar-controls={showSidebarControls ? 'true' : 'false'}
data-page-title={pageTitle ?? ''}
data-module-return={String(showModuleReturn)}
data-peek-open={String(sidebarPeekOpen)}
data-has-peek-handler={String(Boolean(onSidebarPeekChange))}
/>
@@ -93,8 +95,9 @@ describe('MainLayout module isolation', () => {
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-page-title', '我的智能体');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-module-return', 'true');
expect(screen.getByTestId('sidebar-stub')).toHaveAttribute('data-hide-on-compact', 'false');
});
@@ -110,6 +113,7 @@ describe('MainLayout module isolation', () => {
expect(screen.getByTestId('route-content')).toBeVisible();
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-module-return', 'false');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
});
+59 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactElement } from 'react';
import { act, fireEvent, render as renderComponent, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render as renderComponent, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { TitleBar } from '@/components/layout/TitleBar';
import { CodingConversationHeader } from '@/pages/Chat/CodingConversationHeader';
@@ -28,6 +28,64 @@ describe('TitleBar platform behavior', () => {
useSettingsStore.setState({ sidebarCollapsed: false, classroomSidebarWidth: 171 });
});
it.each([
['darwin', 168, 'pl-[88px]'],
['win32', 88, 'pl-3'],
['linux', 88, 'pl-3'],
] as const)('keeps Agents return and collapse together and reachable on %s', (platform, collapsedWidth, inset) => {
window.electron.platform = platform;
renderComponent(<MemoryRouter initialEntries={['/cloud-agents?view=mine']}>
<TitleBar integrated showModuleReturn pageTitle="我的智能体" />
<LocationPath />
</MemoryRouter>);
const navigation = screen.getByTestId('agent-titlebar-navigation');
const back = within(navigation).getByRole('button', { name: '返回首页' });
const toggle = within(navigation).getByRole('button', { name: '折叠侧栏' });
expect(navigation).toHaveClass('flex', 'items-center', inset);
expect(back).toHaveClass('text-brand', 'rounded-full');
expect(back).not.toHaveTextContent('Agents');
expect(screen.getAllByRole('button', { name: '返回首页' })).toHaveLength(1);
expect(screen.getByTestId('titlebar-sidebar-surface')).toHaveStyle({ width: '256px' });
expect(toggle).toHaveClass('right-2');
fireEvent.click(toggle);
expect(screen.getByTestId('titlebar-sidebar-toggle-layer')).toHaveStyle({ width: `${collapsedWidth}px` });
expect(back).toBeVisible();
fireEvent.click(within(navigation).getByRole('button', { name: '展开侧栏' }));
expect(useSettingsStore.getState().sidebarCollapsed).toBe(false);
fireEvent.click(back);
expect(screen.getByTestId('location-path')).toHaveTextContent('/module-select');
});
it('uses the compact Agents navigation inset in macOS fullscreen', async () => {
window.electron.platform = 'darwin';
invokeIpcMock.mockResolvedValue(true);
useSettingsStore.setState({ sidebarCollapsed: true });
renderComponent(<MemoryRouter initialEntries={['/cloud-agents']}>
<TitleBar integrated showModuleReturn />
</MemoryRouter>);
await waitFor(() => expect(screen.getByTestId('agent-titlebar-navigation')).toHaveClass('pl-3'));
expect(screen.getByTestId('titlebar-sidebar-toggle-layer')).toHaveStyle({ width: '88px' });
});
it('keeps Agents keyboard preview open between return and expand controls', () => {
window.electron.platform = 'darwin';
useSettingsStore.setState({ sidebarCollapsed: true });
const onPeek = vi.fn();
renderComponent(<MemoryRouter initialEntries={['/cloud-agents']}>
<TitleBar integrated showModuleReturn sidebarPeekOpen onSidebarPeekChange={onPeek} />
</MemoryRouter>);
const back = screen.getByRole('button', { name: '返回首页' });
const toggle = screen.getByRole('button', { name: '固定侧栏' });
fireEvent.focus(back);
expect(onPeek).toHaveBeenLastCalledWith(true, 'focus');
onPeek.mockClear();
fireEvent.blur(back, { relatedTarget: toggle });
expect(onPeek).not.toHaveBeenCalled();
fireEvent.blur(toggle, { relatedTarget: null });
expect(onPeek).toHaveBeenLastCalledWith(false, 'focus');
expect(screen.getByTestId('titlebar-sidebar-toggle-layer')).toHaveStyle({ width: '256px' });
});
it('extends the product surfaces through the macOS title bar', () => {
window.electron.platform = 'darwin';