feat(chat): show playful teacher introductions on hover

This commit is contained in:
鲨鱼辣椒
2026-09-28 12:12:10 +08:00
parent b26e25c9ed
commit 772da4f8e4
16 changed files with 433 additions and 62 deletions

View File

@@ -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();

View File

@@ -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 }));

View File

@@ -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');
});

View File

@@ -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' } };