Move work preview navigation beside workspace tabs
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState, type ComponentProps } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { MessageSquare, Play } from 'lucide-react';
|
||||
import { TeacherCompanion } from '../../../src/pages/Chat/TeacherCompanion';
|
||||
import { AgentBrowserPanel } from '../../../src/pages/Chat/AgentBrowserPanel';
|
||||
import '../../../src/styles/globals.css';
|
||||
@@ -10,6 +11,8 @@ import '../../../src/pages/Chat/classroom-workspace.css';
|
||||
export function Layout() {
|
||||
const [bubble, setBubble] = useState<HTMLDivElement | null>(null);
|
||||
const [introduction, setIntroduction] = useState<HTMLDivElement | null>(null);
|
||||
const [toolbarContainer, setToolbarContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [workOpen, setWorkOpen] = useState(true);
|
||||
const [visible, setVisible] = useState(!new URLSearchParams(location.search).has('quiet'));
|
||||
const [open, setOpen] = useState(false);
|
||||
const [width, setWidth] = useState(508);
|
||||
@@ -56,10 +59,17 @@ export function Layout() {
|
||||
</header>
|
||||
<div className="relative flex min-h-0 flex-1" data-testid="coding-chat-panel" data-consultation-open={open}>
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="classroom-tabs" role="tablist"><button role="tab">操作对话</button><button role="tab" aria-selected="true">作品</button></div>
|
||||
<div className="classroom-work-view flex min-h-0 flex-1 flex-col p-5" data-testid="work-view">
|
||||
<AgentBrowserPanel projectId="layout-fixture" embedded open teacherBubble={bubble} teacherIntroduction={introduction} onOpenChange={() => {}} />
|
||||
<div className="classroom-workspace-toolbar" data-testid="classroom-workspace-toolbar">
|
||||
<div className="classroom-tabs" role="tablist" aria-label="当前工作">
|
||||
<button role="tab" aria-selected={!workOpen} onClick={() => setWorkOpen(false)}><MessageSquare className="h-3.5 w-3.5" />操作对话</button>
|
||||
<button role="tab" aria-selected={workOpen} onClick={() => setWorkOpen(true)}><Play className="h-3.5 w-3.5" />作品</button>
|
||||
</div>
|
||||
<div className="classroom-browser-toolbar" ref={setToolbarContainer} hidden={!workOpen} />
|
||||
</div>
|
||||
<div hidden={!workOpen} className="classroom-work-view flex min-h-0 flex-1 flex-col p-5" data-testid="work-view">
|
||||
<AgentBrowserPanel projectId="layout-fixture" embedded open={workOpen} toolbarContainer={toolbarContainer} teacherBubble={bubble} teacherIntroduction={introduction} onOpenChange={setWorkOpen} />
|
||||
</div>
|
||||
{!workOpen && <div className="min-h-0 flex-1 p-5">操作对话(布局测试)</div>}
|
||||
</main>
|
||||
{open && <aside id="coding-consultation-dock" className="classroom-consultation-dock consultation-pane shrink-0 border-l p-5" style={{ width }}>
|
||||
<p className="mb-8 text-sm">老师对话(布局测试)</p>
|
||||
|
||||
@@ -2566,6 +2566,29 @@ test('work tab prepares the page automatically and keeps the student draft throu
|
||||
await expect(page.getByRole('button', { name: '收起作品', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId('agent-browser-viewport')).toBeVisible();
|
||||
await expect(page.getByText('正在打开你的作品…', { exact: true })).toHaveCount(0);
|
||||
const workspaceToolbar = page.getByTestId('classroom-workspace-toolbar');
|
||||
const tabs = workspaceToolbar.getByRole('tablist', { name: '当前工作' });
|
||||
const browserToolbar = page.getByTestId('agent-browser-toolbar');
|
||||
const address = browserToolbar.getByRole('textbox', { name: '网页地址' });
|
||||
const workTab = tabs.getByRole('tab', { name: '作品', exact: true });
|
||||
const chatTab = tabs.getByRole('tab', { name: '操作对话', exact: true });
|
||||
await expect(workspaceToolbar.getByTestId('agent-browser-toolbar')).toHaveCount(1);
|
||||
await expect(browserToolbar).toHaveAccessibleName('网页导航');
|
||||
await expect(tabs.getByTestId('agent-browser-toolbar')).toHaveCount(0);
|
||||
await expect(page.getByTestId('agent-browser-panel').getByTestId('agent-browser-toolbar')).toHaveCount(0);
|
||||
const [tabsBounds, toolbarBounds] = await Promise.all([tabs.boundingBox(), browserToolbar.boundingBox()]);
|
||||
expect(tabsBounds).not.toBeNull();
|
||||
expect(toolbarBounds).not.toBeNull();
|
||||
expect(toolbarBounds!.x).toBeGreaterThanOrEqual(tabsBounds!.x + tabsBounds!.width);
|
||||
expect(Math.abs(toolbarBounds!.y + toolbarBounds!.height / 2 - tabsBounds!.y - tabsBounds!.height / 2)).toBeLessThanOrEqual(1);
|
||||
await address.press('ArrowLeft');
|
||||
await expect(address).toBeFocused();
|
||||
await expect(workTab).toHaveAttribute('aria-selected', 'true');
|
||||
await browserToolbar.getByRole('button', { name: '刷新网页', exact: true }).click();
|
||||
await expect.poll(async () => (await readState(electronApp)).captured.filter((item) => item.path === '/api/agent-browser/navigate' && item.body?.action === 'reload').length).toBe(1);
|
||||
await expect(browserToolbar.getByRole('button', { name: '刷新网页', exact: true })).toBeEnabled();
|
||||
await expect(address).toHaveValue('http://127.0.0.1:4173/');
|
||||
await page.screenshot({ path: test.info().outputPath('work-toolbar-with-tabs.png') });
|
||||
// A manual address open must not permanently disable recovery checks.
|
||||
await page.getByRole('textbox', { name: '网页地址' }).fill('http://127.0.0.1:4173/');
|
||||
await page.getByRole('textbox', { name: '网页地址' }).press('Enter');
|
||||
@@ -2586,8 +2609,17 @@ test('work tab prepares the page automatically and keeps the student draft throu
|
||||
await expect(page.getByRole('button', { name: '刷新网页', exact: true })).toBeEnabled();
|
||||
await expect(page.getByText('正在打开你的作品…', { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText('开发浏览器已关闭。', { exact: true })).toHaveCount(0);
|
||||
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
|
||||
await workTab.focus();
|
||||
await workTab.press('ArrowLeft');
|
||||
await expect(chatTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(chatTab).toBeFocused();
|
||||
await expect(browserToolbar).toHaveCount(0);
|
||||
await expect(composer).toHaveValue('还没有发出的想法');
|
||||
await chatTab.press('ArrowRight');
|
||||
await expect(workTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(workTab).toBeFocused();
|
||||
await expect(workspaceToolbar.getByTestId('agent-browser-toolbar')).toBeVisible();
|
||||
await expect(address).toHaveValue('http://127.0.0.1:4173/');
|
||||
const requests = (await readState(electronApp)).captured;
|
||||
const opens = requests.filter((request) => request.path === '/api/agent-browser/ensure-work');
|
||||
expect(opens.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
@@ -34,7 +34,7 @@ test.beforeAll(async () => {
|
||||
vite.middlewares.use((req, res, next) => {
|
||||
if (req.url !== '/' && !req.url?.startsWith('/?')) return next();
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end('<html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/tests/e2e/fixtures/teacher-preview-layout.tsx"></script></body></html>');
|
||||
res.end('<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/tests/e2e/fixtures/teacher-preview-layout.tsx"></script></body></html>');
|
||||
});
|
||||
},
|
||||
}],
|
||||
@@ -44,6 +44,64 @@ test.beforeAll(async () => {
|
||||
});
|
||||
test.afterAll(async () => { await server?.close(); });
|
||||
|
||||
for (const width of [375, 768, 1180]) {
|
||||
test(`browser navigation stays with the workspace tabs without overflow at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto(`${baseUrl}?quiet=1`);
|
||||
const row = page.getByTestId('classroom-workspace-toolbar');
|
||||
const tabs = row.getByRole('tablist', { name: '当前工作' });
|
||||
const toolbar = row.getByRole('form', { name: '网页导航' });
|
||||
const address = toolbar.getByRole('textbox', { name: '网页地址' });
|
||||
await expect(address).toHaveValue('http://127.0.0.1:4173/');
|
||||
await expect(page.getByTestId('agent-browser-toolbar')).toHaveCount(1);
|
||||
await expect(tabs.getByRole('tab')).toHaveCount(2);
|
||||
await expect(tabs.getByRole('textbox')).toHaveCount(0);
|
||||
await expect(page.getByTestId('agent-browser-panel').getByTestId('agent-browser-toolbar')).toHaveCount(0);
|
||||
expect(await toolbar.evaluate(el => el.closest('[role="tablist"]'))).toBeNull();
|
||||
|
||||
const checkLayout = async () => {
|
||||
for (const control of [tabs.getByRole('tab', { name: '操作对话' }), tabs.getByRole('tab', { name: '作品', exact: true }),
|
||||
toolbar.getByRole('button', { name: '后退', exact: true }), toolbar.getByRole('button', { name: '前进', exact: true }),
|
||||
toolbar.getByRole('button', { name: '刷新网页', exact: true }), address]) {
|
||||
await expect(control).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
const [tabBounds, toolbarBounds, rowBounds, previewBounds, viewportBounds] = await Promise.all([
|
||||
tabs.boundingBox(), toolbar.boundingBox(), row.boundingBox(), page.getByTestId('agent-browser-panel').boundingBox(),
|
||||
page.getByTestId('agent-browser-viewport').boundingBox(),
|
||||
]);
|
||||
if (width === 375) {
|
||||
expect(toolbarBounds!.y).toBeGreaterThanOrEqual(tabBounds!.y + tabBounds!.height);
|
||||
} else {
|
||||
expect(Math.abs(tabBounds!.y + tabBounds!.height / 2 - toolbarBounds!.y - toolbarBounds!.height / 2)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
expect(toolbarBounds!.y + toolbarBounds!.height).toBeLessThanOrEqual(rowBounds!.y + rowBounds!.height);
|
||||
expect(viewportBounds!.y).toBeGreaterThan(rowBounds!.y + rowBounds!.height);
|
||||
expect(viewportBounds!.y - previewBounds!.y).toBeLessThanOrEqual(2);
|
||||
expect(await row.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
expect(await toolbar.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
};
|
||||
|
||||
await address.fill(`http://127.0.0.1:4173/${'long-preview-path/'.repeat(12)}`);
|
||||
await checkLayout();
|
||||
await page.screenshot({ path: test.info().outputPath(`workspace-browser-toolbar-${width}.png`) });
|
||||
if (width === 1180) {
|
||||
await page.getByRole('button', { name: '智能体', exact: true }).click();
|
||||
await expect(page.locator('#coding-consultation-dock')).toBeVisible();
|
||||
await checkLayout();
|
||||
await page.screenshot({ path: test.info().outputPath('workspace-browser-toolbar-with-teacher.png') });
|
||||
}
|
||||
await tabs.getByRole('tab', { name: '操作对话' }).click();
|
||||
await expect(page.getByTestId('agent-browser-toolbar')).toHaveCount(0);
|
||||
await expect(tabs.getByRole('tab', { name: '操作对话' })).toHaveAttribute('aria-selected', 'true');
|
||||
await tabs.getByRole('tab', { name: '作品', exact: true }).click();
|
||||
await expect(toolbar).toBeVisible();
|
||||
await expect(address).toHaveValue('http://127.0.0.1:4173/');
|
||||
await expect(page.getByTestId('agent-browser-toolbar')).toHaveCount(1);
|
||||
await checkLayout();
|
||||
});
|
||||
}
|
||||
|
||||
test('empty work shows a local hint without opening the teacher or polling', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1180, height: 720 });
|
||||
await page.clock.install();
|
||||
@@ -256,7 +314,9 @@ for (const size of [{ width: 1440, height: 900 }, { width: 1180, height: 720 },
|
||||
await page.goto(baseUrl);
|
||||
await expect(page.getByTestId('teacher-invitation')).toBeVisible();
|
||||
await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8);
|
||||
expect((await geometry(page)).gap).toBeLessThan(65);
|
||||
// Toolbar placement does not affect the native pixels' clearance: leave
|
||||
// only the required 8px below the speech bubble, allowing pixel rounding.
|
||||
expect((await geometry(page)).nativeGap).toBeLessThanOrEqual(10);
|
||||
await page.getByRole('button', { name: '智能体', exact: true }).click();
|
||||
await expect.poll(async () => (await geometry(page)).sideBySide).toBe(true);
|
||||
await expect.poll(async () => (await geometry(page)).gap).toBe(20);
|
||||
@@ -269,11 +329,16 @@ for (const size of [{ width: 1440, height: 900 }, { width: 1180, height: 720 },
|
||||
await page.screenshot({ path: test.info().outputPath('work-with-teacher-no-gap.png') });
|
||||
await page.getByRole('button', { name: '关闭智能体栏' }).click();
|
||||
await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8);
|
||||
const longMessageClearance = (await geometry(page)).clearance;
|
||||
await page.getByRole('button', { name: '切换消息长度' }).click();
|
||||
await expect.poll(async () => (await geometry(page)).gap).toBe(20);
|
||||
await expect.poll(async () => (await geometry(page)).clearance).toBeLessThan(longMessageClearance);
|
||||
await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8);
|
||||
expect((await geometry(page)).nativeGap).toBeLessThanOrEqual(10);
|
||||
await page.getByRole('button', { name: '等会儿聊' }).click();
|
||||
await expect(page.getByTestId('teacher-invitation')).toHaveCount(0);
|
||||
await expect(page.getByTestId('agent-browser-panel')).toHaveCSS('margin-top', '0px');
|
||||
expect(await page.evaluate(() => document.querySelector('[data-testid="agent-browser-panel"]')!.getBoundingClientRect().top
|
||||
- document.querySelector('[data-testid="work-view"]')!.getBoundingClientRect().top)).toBe(20);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AgentBrowserPanel } from '@/pages/Chat/AgentBrowserPanel';
|
||||
@@ -341,6 +341,7 @@ describe('AgentBrowserPanel', () => {
|
||||
expect(screen.queryByRole('heading', { name: '作品预览' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '收起作品' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: '网页地址' })).toHaveValue('http://127.0.0.1:4173/');
|
||||
expect(within(screen.getByTestId('agent-browser-panel')).getByRole('form', { name: '网页导航' })).toBeVisible();
|
||||
expect(screen.getByRole('textbox', { name: '网页地址' })).toBeEnabled();
|
||||
expect(screen.getByTestId('agent-browser-viewport')).toBeVisible();
|
||||
expect(screen.getByTestId('agent-browser-viewport')).toHaveAccessibleName('作品画面');
|
||||
@@ -364,6 +365,60 @@ describe('AgentBrowserPanel', () => {
|
||||
expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/close')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('portals the complete embedded toolbar and keeps navigation working across tab visibility', async () => {
|
||||
const original = hostApiFetchMock.getMockImplementation()!;
|
||||
let currentUrl = 'http://127.0.0.1:4173/';
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/api/agent-browser/navigate') {
|
||||
if (body(init).action === 'url') currentUrl = String(body(init).url);
|
||||
return { success: true, browser: snapshot({ url: currentUrl, canGoBack: true, canGoForward: true }) };
|
||||
}
|
||||
const result = await original(path, init);
|
||||
return result.browser ? { ...result, browser: { ...result.browser, url: currentUrl, canGoBack: true, canGoForward: true } } : result;
|
||||
});
|
||||
const toolbarContainer = document.createElement('div');
|
||||
document.body.append(toolbarContainer);
|
||||
const onOpenChange = vi.fn();
|
||||
const view = render(<AgentBrowserPanel projectId="project-a" open embedded toolbarContainer={toolbarContainer} onOpenChange={onOpenChange} />);
|
||||
try {
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
|
||||
const toolbar = within(toolbarContainer).getByRole('form', { name: '网页导航' });
|
||||
expect(screen.getAllByTestId('agent-browser-toolbar')).toHaveLength(1);
|
||||
expect(within(screen.getByTestId('agent-browser-panel')).queryByRole('form')).not.toBeInTheDocument();
|
||||
expect(within(toolbar).getByRole('textbox', { name: '网页地址' })).toHaveValue(currentUrl);
|
||||
for (const name of ['后退', '前进', '刷新网页']) {
|
||||
const button = within(toolbar).getByRole('button', { name, exact: true });
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
}
|
||||
fireEvent.change(within(toolbar).getByRole('textbox', { name: '网页地址' }), { target: { value: 'http://127.0.0.1:4173/next' } });
|
||||
fireEvent.submit(toolbar);
|
||||
await waitFor(() => expect(within(toolbar).getByRole('button', { name: '刷新网页' })).toBeEnabled());
|
||||
expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/navigate').map(([, init]) => body(init))).toEqual([
|
||||
{ project_id: 'project-a', action: 'back' },
|
||||
{ project_id: 'project-a', action: 'forward' },
|
||||
{ project_id: 'project-a', action: 'reload' },
|
||||
{ project_id: 'project-a', action: 'url', url: 'http://127.0.0.1:4173/next' },
|
||||
]);
|
||||
|
||||
view.rerender(<AgentBrowserPanel projectId="project-a" open={false} embedded toolbarContainer={toolbarContainer} onOpenChange={onOpenChange} />);
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: false }));
|
||||
expect(toolbarContainer).toBeEmptyDOMElement();
|
||||
expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument();
|
||||
view.rerender(<AgentBrowserPanel projectId="project-a" open embedded toolbarContainer={toolbarContainer} onOpenChange={onOpenChange} />);
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
|
||||
expect(within(toolbarContainer).getByRole('textbox', { name: '网页地址' })).toHaveValue('http://127.0.0.1:4173/next');
|
||||
expect(screen.getAllByTestId('agent-browser-toolbar')).toHaveLength(1);
|
||||
expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/navigate')).toHaveLength(4);
|
||||
expect(hostApiFetchMock.mock.calls.some(([path]) => ['/api/agent-browser/open', '/api/agent-browser/close'].includes(String(path)))).toBe(false);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
view.unmount();
|
||||
toolbarContainer.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['absolute', 'fixed'])('hides the native work page under a %s consultation and restores it beside a relative sidebar', async (position) => {
|
||||
const onOpenChange = vi.fn();
|
||||
const content = (dockPosition: string) => <>
|
||||
|
||||
Reference in New Issue
Block a user