feat(chat): show playful teacher introductions on hover
This commit is contained in:
@@ -9,7 +9,8 @@ import '../../../src/pages/Chat/classroom-workspace.css';
|
||||
// window is launched, and no user project, provider, or live Host API is used.
|
||||
export function Layout() {
|
||||
const [bubble, setBubble] = useState<HTMLDivElement | null>(null);
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [introduction, setIntroduction] = useState<HTMLDivElement | null>(null);
|
||||
const [visible, setVisible] = useState(!new URLSearchParams(location.search).has('quiet'));
|
||||
const [open, setOpen] = useState(false);
|
||||
const [width, setWidth] = useState(508);
|
||||
const [long, setLong] = useState(true);
|
||||
@@ -18,6 +19,7 @@ export function Layout() {
|
||||
const agents = Array.from({ length: count }, (_, index) => ({
|
||||
teacher_id: `agent-${index}`, version: index + 1, is_default: index === 0,
|
||||
definition: { config_id: `agent-${index}`, name: `智能体${index + 1}`, avatar_id: 'avatar-06',
|
||||
description: `我是智能体${index + 1},陪你把脑洞变成小作品。一起发现有趣的问题,也一起想清楚下一步怎么做。`,
|
||||
icon_url: new URLSearchParams(location.search).has('icons') ? `https://yuxi.example.test/api/storage/public/images/agent-${index}.png` : undefined },
|
||||
}));
|
||||
const companion = {
|
||||
@@ -30,14 +32,17 @@ export function Layout() {
|
||||
return <div className="classroom-workspace flex h-screen flex-col bg-white">
|
||||
<header className="relative z-50 flex h-10 shrink-0 items-center justify-end border-b px-3" data-testid="coding-conversation-header">
|
||||
<span className="mr-auto min-w-0 truncate text-xs">布局测试项目 › hello</span>
|
||||
<TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(!open); }} bubbleRef={setBubble} />
|
||||
<TeacherCompanion companion={companion} open={open}
|
||||
onOpen={(id) => { if (id) setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(true); }}
|
||||
onToggle={(id) => { if (!id || id === agents[selected]?.teacher_id) setOpen(!open); else { setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(true); } }}
|
||||
bubbleRef={setBubble} introductionRef={setIntroduction} />
|
||||
<span data-testid="window-controls" className="w-[132px] shrink-0 text-center text-xs">— □ ×</span>
|
||||
</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} onOpenChange={() => {}} />
|
||||
<AgentBrowserPanel projectId="layout-fixture" embedded open teacherBubble={bubble} teacherIntroduction={introduction} onOpenChange={() => {}} />
|
||||
</div>
|
||||
</main>
|
||||
{open && <aside id="coding-consultation-dock" className="classroom-consultation-dock consultation-pane shrink-0 border-l p-5" style={{ width }}>
|
||||
|
||||
@@ -454,10 +454,10 @@ async function installCodingFirstChatHost(
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const teacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
|
||||
const teacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'一起发现代码里的小奥秘,把想法变成作品。',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
|
||||
const teachers = [
|
||||
{ teacher_id: 'teacher-code', version: 1, is_default: true, definition: { ...teacherDefinition, config_id: 'teacher-code', runtime: 'yuxi', system_prompt: '', yuxi: { agent_slug: 'code', agent_version: 2 } } },
|
||||
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '朋友', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
|
||||
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '朋友', description: '陪你听听玩家的想法,让作品更有趣。', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
|
||||
];
|
||||
const consultationTopics: Record<string, Record<string, unknown>> = {};
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
@@ -1786,8 +1786,18 @@ test('project consultations preserve student drafts and switch between work and
|
||||
await expect(composer).toBeHidden();
|
||||
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
|
||||
await expect(composer).toHaveValue('保留我的草稿');
|
||||
await page.getByRole('button',{name:'与代码智能体聊天',exact:true}).hover();
|
||||
await expect(page.getByTestId('teacher-introduction')).toContainText('一起发现代码里的小奥秘,把想法变成作品。');
|
||||
await page.getByRole('button',{name:'与朋友聊天',exact:true}).hover();
|
||||
await expect(page.getByTestId('teacher-introduction')).toHaveCount(1);
|
||||
await expect(page.getByTestId('teacher-introduction')).toContainText('陪你听听玩家的想法,让作品更有趣。');
|
||||
await expect(page.getByTestId('teacher-chat-panel')).toHaveCount(0);
|
||||
expect((await readState(electronApp)).captured.filter(item => /\/(messages|teacher-check-in)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
|
||||
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
|
||||
const teacher=page.getByTestId('teacher-chat-panel');
|
||||
await expect(page.getByTestId('teacher-introduction')).toHaveCount(0);
|
||||
await expect(teacher.locator('header').getByRole('button')).toHaveCount(0);
|
||||
await expect(teacher.getByText('陪你听听玩家的想法,让作品更有趣。')).toHaveCount(0);
|
||||
await expect(page.getByRole('tab',{name:'作品',exact:true})).toHaveAttribute('aria-selected','true');
|
||||
await expect(page.getByRole('tabpanel',{name:'作品',exact:true})).toBeVisible();
|
||||
await expect(composer).toBeHidden();
|
||||
@@ -1819,7 +1829,9 @@ test('project consultations preserve student drafts and switch between work and
|
||||
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
|
||||
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
|
||||
await expect(composer).toBeVisible();
|
||||
await teacher.getByRole('button',{name:'关闭智能体'}).click();
|
||||
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
|
||||
await expect(teacher).toHaveCount(0);
|
||||
await expect(page.getByRole('button',{name:'与朋友聊天',exact:true})).toBeFocused();
|
||||
await page.getByRole('button', { name: '与代码智能体聊天' }).click();
|
||||
await expect(teacher.getByRole('heading', { name: '代码智能体' })).toBeVisible();
|
||||
await expect(teacher.getByRole('textbox', { name: '向智能体提问' })).toHaveValue('');
|
||||
@@ -1827,6 +1839,11 @@ test('project consultations preserve student drafts and switch between work and
|
||||
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
|
||||
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
|
||||
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
|
||||
// Moving inside the clicked entry must not schedule its introduction again.
|
||||
const friendEntry = (await page.getByRole('button', { name: '与朋友聊天', exact: true }).boundingBox())!;
|
||||
await page.mouse.move(friendEntry.x + friendEntry.width / 2 + 2, friendEntry.y + friendEntry.height / 2);
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.getByTestId('teacher-introduction')).toHaveCount(0);
|
||||
const requests=(await readState(electronApp)).captured;
|
||||
expect(requests.filter(item=>item.path.endsWith('/prompt')&&item.method==='POST')).toHaveLength(0);
|
||||
const consultationRequests=requests.filter(item=>item.path.endsWith('/messages')&&item.method==='POST');
|
||||
@@ -1838,7 +1855,7 @@ test('project consultations preserve student drafts and switch between work and
|
||||
expect(consultationRequests[1].body?.intent).toBeUndefined();
|
||||
expect(consultationRequests[2].body).toMatchObject({intent:'guided-help',text:'我也说不清,你带我看看'});
|
||||
await page.screenshot({path:test.info().outputPath('project-teacher-side-chat.png')});
|
||||
await teacher.getByRole('button',{name:'关闭智能体'}).click();await expect(teacher).toHaveCount(0);
|
||||
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();await expect(teacher).toHaveCount(0);
|
||||
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
|
||||
await expect(page.getByText('先理解状态如何随点击变化,再修改代码。',{exact:true})).toBeVisible();
|
||||
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
|
||||
@@ -1900,9 +1917,9 @@ test('teacher stays in the top-right header and proactively checks in without ta
|
||||
await page.reload(); page = await getStableWindow(electronApp);
|
||||
await page.getByTestId('ai-module-option-programming').click();
|
||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||
const header = page.getByTestId('coding-conversation-header');
|
||||
const companion = header.getByTestId('teacher-companion');
|
||||
const teacherButton = companion.getByRole('button', { name: '智能体', exact: true });
|
||||
const header = page.getByTestId('coding-conversation-header-titlebar');
|
||||
const companion = page.getByTestId('teacher-companion');
|
||||
const teacherButton = companion.getByRole('button', { name: '与代码智能体聊天', exact: true });
|
||||
await expect(teacherButton).toHaveText('代码智能体');
|
||||
await expect(teacherButton).toBeVisible();
|
||||
await expect(teacherButton.locator('img')).toHaveAttribute('src', /avatar-01/);
|
||||
@@ -1926,6 +1943,22 @@ test('teacher stays in the top-right header and proactively checks in without ta
|
||||
await composer.fill('我还在写自己的想法');
|
||||
await page.getByRole('tab', { name: '作品', exact: true }).click();
|
||||
await expect(teacherButton).toBeVisible();
|
||||
await teacherButton.hover();
|
||||
await page.clock.runFor(250);
|
||||
const introduction = page.getByTestId('teacher-introduction');
|
||||
await expect(introduction).toContainText('一起发现代码里的小奥秘,把想法变成作品。');
|
||||
await expect(introduction).toBeInViewport({ ratio: 1 });
|
||||
const [introductionBounds, introductionViewportBounds] = await Promise.all([
|
||||
introduction.boundingBox(), page.getByTestId('agent-browser-viewport').boundingBox(),
|
||||
]);
|
||||
// Native WebContentsView must leave room for the portaled introduction too.
|
||||
expect(introductionBounds).not.toBeNull();
|
||||
expect(introductionViewportBounds).not.toBeNull();
|
||||
expect(introductionViewportBounds!.y).toBeGreaterThanOrEqual(introductionBounds!.y + introductionBounds!.height);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(introduction).toHaveCount(0);
|
||||
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
|
||||
expect((await readState(electronApp)).captured.filter(item => /\/(messages|teacher-check-in)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
|
||||
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
|
||||
await composer.focus();
|
||||
await page.clock.fastForward(300_000);
|
||||
@@ -1977,9 +2010,10 @@ test('teacher stays in the top-right header and proactively checks in without ta
|
||||
const teacherInput = teacher.getByRole('textbox', { name: '向智能体提问' });
|
||||
await expect(teacherInput).toBeFocused();
|
||||
await teacherInput.fill('我想比谁更了解自己的宠物');
|
||||
await teacher.getByRole('button', { name: '关闭智能体' }).click();
|
||||
await expect(page.getByRole('button', { name: '智能体', exact: true })).toBeFocused();
|
||||
await page.getByRole('button', { name: '智能体', exact: true }).click();
|
||||
await teacherInput.press('Escape');
|
||||
await expect(teacher).toHaveCount(0);
|
||||
await expect(teacherButton).toBeFocused();
|
||||
await teacherButton.click();
|
||||
await expect(teacherInput).toHaveValue('我想比谁更了解自己的宠物');
|
||||
await teacherInput.fill('');
|
||||
// Let the cleared draft propagate through the parent before advancing the check-in timer.
|
||||
|
||||
@@ -70,6 +70,76 @@ async function geometry(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [520, 1000]) {
|
||||
test(`teacher introduction floats outside the scrolling strip and clears the work at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto(`${baseUrl}?agents=8&quiet=1`);
|
||||
const first = page.getByRole('button', { name: '与智能体1聊天', exact: true });
|
||||
const card = page.getByTestId('teacher-introduction');
|
||||
await expect(first).not.toHaveAttribute('title');
|
||||
await first.hover();
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体1');
|
||||
await page.getByRole('button', { name: '与智能体2聊天', exact: true }).hover();
|
||||
await expect(card).toHaveCount(1);
|
||||
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体2');
|
||||
await first.hover();
|
||||
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体1');
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const card = document.querySelector('[data-testid="teacher-introduction"]')!.getBoundingClientRect();
|
||||
const viewport = document.querySelector('[data-testid="agent-browser-viewport"]')!.getBoundingClientRect();
|
||||
return card.left >= 0 && card.right <= innerWidth && card.bottom <= innerHeight
|
||||
&& (card.left >= viewport.right || card.right <= viewport.left || viewport.top - card.bottom >= 8);
|
||||
})).toBe(true);
|
||||
await card.hover();
|
||||
await expect(card).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath('teacher-hover-introduction.png') });
|
||||
const cardBounds = (await card.boundingBox())!;
|
||||
await page.screenshot({ path: test.info().outputPath('teacher-introduction-card.png'), clip: {
|
||||
x: Math.max(0, cardBounds.x - 12), y: cardBounds.y - 12,
|
||||
width: cardBounds.width + 24, height: cardBounds.height + 30,
|
||||
} });
|
||||
await page.mouse.move(0, 780);
|
||||
await expect(card).toHaveCount(0);
|
||||
await expect(page.getByTestId('agent-browser-panel')).toHaveCSS('margin-top', '0px');
|
||||
|
||||
const last = page.getByRole('button', { name: '与智能体8聊天', exact: true });
|
||||
await last.focus();
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体8');
|
||||
await last.press('Escape');
|
||||
await expect(card).toHaveCount(0);
|
||||
await expect(last).toBeFocused();
|
||||
await last.hover();
|
||||
await expect(card).toBeVisible();
|
||||
await last.click();
|
||||
await expect(card).toHaveCount(0);
|
||||
await expect(page.locator('#coding-consultation-dock')).toBeVisible();
|
||||
const lastBounds = (await last.boundingBox())!;
|
||||
await page.mouse.move(lastBounds.x + lastBounds.width / 2 + 2, lastBounds.y + lastBounds.height / 2);
|
||||
await page.waitForTimeout(300);
|
||||
await expect(card).toHaveCount(0);
|
||||
await page.mouse.move(0, 780);
|
||||
await last.hover();
|
||||
await expect(card).toBeVisible();
|
||||
await last.click();
|
||||
await expect(page.locator('#coding-consultation-dock')).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('closing an introduction preserves the unread speech and its native preview clearance', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1180, height: 720 });
|
||||
await page.goto(`${baseUrl}?agents=2`);
|
||||
await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8);
|
||||
const original = (await geometry(page)).clearance;
|
||||
await page.getByRole('button', { name: '与智能体2聊天', exact: true }).hover();
|
||||
await expect(page.getByTestId('teacher-introduction')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('teacher-introduction')).toHaveCount(0);
|
||||
await expect(page.getByTestId('teacher-invitation')).toBeVisible();
|
||||
await expect.poll(async () => (await geometry(page)).clearance).toBe(original);
|
||||
});
|
||||
|
||||
test('distributed icons load over HTTP and broken images fall back in the titlebar', async ({ page }) => {
|
||||
await page.route('https://yuxi.example.test/api/storage/public/images/agent-0.png', route => route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
|
||||
@@ -306,12 +306,14 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
fireEvent.click(chatTab);
|
||||
expect(operationInput).toBeVisible();
|
||||
expect(teacherInput).toHaveValue('怎样判断规则清不清楚?');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭智能体' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }));
|
||||
expect(screen.queryByRole('textbox', { name: '向智能体提问' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '朋友', exact: true })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }));
|
||||
expect(screen.getByRole('textbox', { name: '向智能体提问' })).toHaveValue('怎样判断规则清不清楚?');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭智能体' }));
|
||||
fireEvent.keyDown(screen.getByRole('textbox', { name: '向智能体提问' }), { key: 'Escape' });
|
||||
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveFocus();
|
||||
expect(consultationCreate).not.toHaveBeenCalled();
|
||||
expect(consultationSend).not.toHaveBeenCalled();
|
||||
expect(conversationApi.submit).not.toHaveBeenCalled();
|
||||
|
||||
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
|
||||
import type { TeacherDefinition, TeacherRequest, TeacherSend, TeacherTopic } from '../../shared/coding-teacher';
|
||||
@@ -96,6 +96,24 @@ async function ready() {
|
||||
await waitFor(() => expect(streams.has('first')).toBe(true));
|
||||
}
|
||||
describe('teacher side chat', () => {
|
||||
it('keeps the panel header focused on identity and puts new topics inside expanded history', async () => {
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
const history = await screen.findByRole('button', { name: '以往讨论' });
|
||||
const header = screen.getByTestId('teacher-chat-panel').querySelector('header')!;
|
||||
expect(within(header).getByRole('heading', { name: '智能体' })).toBeVisible();
|
||||
expect(within(header).queryByRole('button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('服务端简介')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '智能体新话题' })).not.toBeInTheDocument();
|
||||
fireEvent.click(history);
|
||||
const newTopic = screen.getByRole('button', { name: '智能体新话题' });
|
||||
expect(newTopic).toBeEnabled();
|
||||
expect(newTopic).toHaveTextContent('新话题');
|
||||
expect(header.contains(newTopic)).toBe(false);
|
||||
fireEvent.click(newTopic);
|
||||
await waitFor(() => expect(api.create).toHaveBeenCalledWith('p/agent-topics', undefined, undefined, 1));
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the pinned topic icon in the header and assistant messages', async () => {
|
||||
const icon = 'https://yuxi.example.test/api/storage/public/images/topic.png';
|
||||
api.read.mockResolvedValue({ ...first, definition: { ...definition, icon_url: icon }, requests: [request({ response: '继续原来的讨论' })] });
|
||||
@@ -221,16 +239,15 @@ describe('teacher side chat', () => {
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('解释代码');
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||
await screen.findByText('教学回答');
|
||||
expect(screen.getByText('服务端简介')).toBeVisible();
|
||||
expect(screen.queryByText('服务端简介')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('较长的上下文已节选,智能体可按需读取原文。')).toBeVisible();
|
||||
expect(api.send.mock.calls[0][2].requestId).toBe(api.send.mock.calls[1][2].requestId);
|
||||
expect(bringBack).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '带回主会话草稿' }));
|
||||
expect(bringBack).toHaveBeenCalledWith('教学回答');
|
||||
});
|
||||
it('ignores an old topic stream after switching and does not cancel when closed', async () => {
|
||||
const closed = vi.fn();
|
||||
const view = render(<TeacherChatPanel projectId="p" sourceId="c" onClose={closed} />);
|
||||
it('ignores an old topic stream after switching and does not cancel when the panel unmounts', async () => {
|
||||
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await ready();
|
||||
const old = streams.get('first')!;
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '智能体话题' }), {
|
||||
@@ -245,8 +262,6 @@ describe('teacher side chat', () => {
|
||||
)
|
||||
);
|
||||
expect(screen.getByRole('combobox', { name: '智能体话题' })).toHaveValue('second');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭智能体' }));
|
||||
expect(closed).toHaveBeenCalledOnce();
|
||||
view.unmount();
|
||||
expect(api.cancel).not.toHaveBeenCalled();
|
||||
expect(old.close).toHaveBeenCalled();
|
||||
@@ -297,7 +312,8 @@ describe('teacher side chat', () => {
|
||||
api.catalog.mockResolvedValue({ items: [{ teacher_id: 'published', version: 2, is_default: true, definition: published }] });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await screen.findByRole('heading', { name, exact: true });
|
||||
expect(screen.getByText('这是运营发布的简介')).toBeVisible();
|
||||
expect(screen.queryByText('这是运营发布的简介')).not.toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('teacher-chat-panel').querySelector('header')!).queryByRole('button')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('服务端欢迎语')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '服务端推荐问题' })).toBeEnabled();
|
||||
expect(screen.getByTestId('teacher-chat-panel').querySelector('img')?.src).toContain('avatar-09.png');
|
||||
@@ -681,6 +697,8 @@ describe('merged cloud teacher classroom', () => {
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={{ teacher_id: 'algorithm', version: 9, definition: cloudDefinition, is_default: false }} />);
|
||||
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
|
||||
expect(screen.queryByLabelText('新话题使用的智能体')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '以往讨论' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '智能体新话题' })).not.toBeInTheDocument();
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我想做一个小游戏' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||
|
||||
@@ -6,19 +6,22 @@ import { usePreviewClearance } from '@/pages/Chat/use-preview-clearance';
|
||||
let resize: () => void;
|
||||
let bubbleRect: DOMRect;
|
||||
let pageRect: DOMRect;
|
||||
let introductionRect: DOMRect;
|
||||
const bubble = document.createElement('div');
|
||||
const introduction = document.createElement('div');
|
||||
const rect = (x: number, y: number, width: number, height: number) => new DOMRect(x, y, width, height);
|
||||
|
||||
function Harness({ visible = true, open = true }: { visible?: boolean; open?: boolean }) {
|
||||
function Harness({ visible = true, open = true, intro = false }: { visible?: boolean; open?: boolean; intro?: boolean }) {
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
usePreviewClearance(panel, viewport, visible ? bubble : null, open);
|
||||
usePreviewClearance(panel, viewport, visible ? bubble : null, open, intro ? introduction : null);
|
||||
return <div ref={panel} data-testid="panel"><div ref={viewport} data-testid="viewport" /></div>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
bubbleRect = rect(960, 48, 340, 164);
|
||||
pageRect = rect(192, 184, 1200, 600);
|
||||
introductionRect = rect(950, 50, 304, 290);
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
constructor(callback: () => void) { resize = callback; }
|
||||
observe() {}
|
||||
@@ -26,6 +29,7 @@ beforeEach(() => {
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this === bubble) return bubbleRect;
|
||||
if (this === introduction) return introductionRect;
|
||||
const offset = Number.parseFloat(this.parentElement?.style.marginTop ?? '') || 0;
|
||||
return rect(pageRect.x, pageRect.y + offset, pageRect.width, pageRect.height - offset);
|
||||
});
|
||||
@@ -73,3 +77,22 @@ it('removes clearance when the bubble is dismissed or Work closes', () => {
|
||||
view.rerender(<Harness open={false} />);
|
||||
expect(screen.getByTestId('panel').style.marginTop).toBe('');
|
||||
});
|
||||
|
||||
it('clears a hovered introduction even when no proactive bubble exists', () => {
|
||||
const view = render(<Harness visible={false} intro />);
|
||||
expect(screen.getByTestId('panel').style.marginTop).toBe('164px');
|
||||
act(() => { resize(); resize(); });
|
||||
expect(screen.getByTestId('panel').style.marginTop).toBe('164px');
|
||||
view.rerender(<Harness visible={false} intro={false} />);
|
||||
expect(screen.getByTestId('panel').style.marginTop).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the deeper overlapping surface clear without adding both heights', () => {
|
||||
const view = render(<Harness intro />);
|
||||
expect(screen.getByTestId('panel').style.marginTop).toBe('164px');
|
||||
introductionRect = rect(950, 50, 304, 100);
|
||||
act(() => resize());
|
||||
expect(screen.getByTestId('panel').style.marginTop).toBe('36px');
|
||||
view.rerender(<Harness intro={false} />);
|
||||
expect(screen.getByTestId('panel').style.marginTop).toBe('36px');
|
||||
});
|
||||
|
||||
@@ -20,8 +20,12 @@ function Harness({ projectId = 'project', sourceId = 'source', busy = false, arc
|
||||
const [open, setOpen] = useState(false);
|
||||
const [teacherComposing, setTeacherComposing] = useState(false);
|
||||
const companion = useTeacherCompanion({ projectId, sourceId, sourceBusy: busy, sourceArchived: archived, teacherOpen: open, teacherComposing });
|
||||
return <><TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) companion.selectAgent(id); setOpen(true); }} />
|
||||
{open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} onClose={() => setOpen(false)} />}</>;
|
||||
return <><TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) companion.selectAgent(id); setOpen(true); }}
|
||||
onToggle={(id) => {
|
||||
if (open && (!id || id === companion.selectedAgent?.teacher_id)) setOpen(false);
|
||||
else { if (id) companion.selectAgent(id); setOpen(true); }
|
||||
}} />
|
||||
{open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} />}</>;
|
||||
}
|
||||
async function mount(props: Parameters<typeof Harness>[0] = {}) {
|
||||
let view!: ReturnType<typeof render>;
|
||||
@@ -33,6 +37,11 @@ async function advance(ms = TEACHER_CHECK_IN_INTERVAL_MS) {
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear();
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
});
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
api.config.mockResolvedValue({ enabled: true, definition });
|
||||
api.catalog.mockResolvedValue({ items: [] });
|
||||
@@ -40,9 +49,88 @@ beforeEach(() => {
|
||||
api.events.mockImplementation(async () => Object.assign(new EventTarget(), { close: vi.fn() }));
|
||||
api.checkIn.mockImplementation(async (projectId: string) => ({ topic: checkInTopic(projectId) }));
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe('teacher presence and periodic guidance', () => {
|
||||
it('shows the hovered agent introduction in a portal without selecting it or sending a request', async () => {
|
||||
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师', description: '把大问题拆成小积木,一起试试看。' } };
|
||||
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友', description: '陪你听听玩家的想法,让作品更有趣。' } };
|
||||
api.catalog.mockResolvedValue({ items: [a, b] });
|
||||
const view = await mount();
|
||||
const method = screen.getByRole('button', { name: '与方法老师聊天' });
|
||||
const experience = screen.getByRole('button', { name: '与体验朋友聊天' });
|
||||
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
|
||||
fireEvent.pointerEnter(method, { pointerType: 'mouse' });
|
||||
await advance(250);
|
||||
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(a.definition.description);
|
||||
expect(view.container.contains(screen.getByTestId('teacher-introduction'))).toBe(false);
|
||||
fireEvent.pointerLeave(method, { pointerType: 'mouse' });
|
||||
fireEvent.pointerMove(document.body, { pointerType: 'mouse', clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerEnter(experience, { pointerType: 'mouse' });
|
||||
await advance(250);
|
||||
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(b.definition.description);
|
||||
expect(screen.getByTestId('teacher-introduction')).not.toHaveTextContent(a.definition.description);
|
||||
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
|
||||
expect(experience).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
await act(async () => fireEvent.click(experience));
|
||||
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
||||
// The first move may arrive only after the click when crossing a tooltip's
|
||||
// grace area. It must not reopen the introduction beneath the pointer.
|
||||
fireEvent.pointerMove(experience, { pointerType: 'mouse' });
|
||||
await advance(250);
|
||||
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
|
||||
fireEvent.pointerLeave(experience, { pointerType: 'mouse' });
|
||||
fireEvent.pointerMove(document.body, { pointerType: 'mouse', clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerEnter(experience, { pointerType: 'mouse' });
|
||||
await advance(250);
|
||||
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(b.definition.description);
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens an introduction on keyboard focus and Escape leaves an unread invitation unacknowledged', async () => {
|
||||
const unread = { ...checkInTopic(), definition: { ...definition, description: '一起发现代码里的小奥秘。' } };
|
||||
api.list.mockResolvedValue({ items: [{ id: unread.id }], lastSelectedTopicId: unread.id });
|
||||
api.read.mockResolvedValue(unread);
|
||||
const view = await mount();
|
||||
const button = screen.getByRole('button', { name: '智能体', exact: true });
|
||||
await act(async () => button.focus());
|
||||
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(unread.definition.description);
|
||||
fireEvent.keyDown(button, { key: 'Escape' });
|
||||
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'check-in');
|
||||
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
|
||||
view.unmount();
|
||||
await mount();
|
||||
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show an empty introduction and preserves a draft when the same entry closes and reopens the panel', async () => {
|
||||
await mount();
|
||||
const button = screen.getByRole('button', { name: '智能体', exact: true });
|
||||
fireEvent.pointerEnter(button, { pointerType: 'mouse' });
|
||||
await advance(250);
|
||||
await act(async () => button.focus());
|
||||
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
|
||||
await act(async () => fireEvent.click(button));
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我还没想好怎么问' } });
|
||||
await act(async () => fireEvent.click(button));
|
||||
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
await act(async () => fireEvent.click(button));
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我还没想好怎么问');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses each published icon in the topbar, welcome and panel, and falls back after an image error', async () => {
|
||||
const a = { teacher_id: 'a', version: 1, is_default: true, definition: { ...definition, icon_url: 'https://yuxi.example.test/api/storage/public/images/a.png' } };
|
||||
const b = { teacher_id: 'b', version: 2, is_default: false, definition: { ...definition, name: '另一个智能体', icon_url: 'https://yuxi.example.test/api/storage/public/images/b.webp' } };
|
||||
|
||||
Reference in New Issue
Block a user