feat(ui): introduce each teacher with an individual welcome bubble
This commit is contained in:
@@ -18,6 +18,8 @@ export function Layout() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshStatus, setRefreshStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [selected, setSelected] = useState(0);
|
||||
const welcomeMode = new URLSearchParams(location.search).has('welcomes');
|
||||
const [dismissed, setDismissed] = useState<string[]>([]);
|
||||
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',
|
||||
@@ -27,8 +29,11 @@ export function Layout() {
|
||||
const companion = {
|
||||
agents, unreadAgentIds: [], selectedAgent: agents[selected],
|
||||
definition: agents[selected]?.definition ?? { name: '编程老师', avatar_id: 'avatar-06' },
|
||||
invitation: visible ? { response: long ? '你刚才说想做一个宠物游戏。我们一起想想,什么样的小动作,会让你愿意一直陪它玩下去?可以先挑一个最想试的想法。' : '有什么编程问题想一起讨论?' } : null,
|
||||
invitation: !welcomeMode && visible ? { response: long ? '你刚才说想做一个宠物游戏。我们一起想想,什么样的小动作,会让你愿意一直陪它玩下去?可以先挑一个最想试的想法。' : '有什么编程问题想一起讨论?' } : null,
|
||||
welcome: null, enabled: true, loading: false, checking: false, error: null,
|
||||
welcomes: welcomeMode && !open ? agents.flatMap((agent, index) => dismissed.includes(agent.teacher_id) ? [] : [{ agentId: agent.teacher_id, definition: agent.definition, text: agent.definition.description, tone: index % 3 }]) : [],
|
||||
dismissWelcome: (id: string) => setDismissed(ids => [...new Set([...ids, id])]),
|
||||
dismissWelcomes: () => setDismissed(agents.map(agent => agent.teacher_id)),
|
||||
refreshing, refreshStatus,
|
||||
refresh: async () => {
|
||||
setRefreshing(true);
|
||||
|
||||
@@ -2186,9 +2186,9 @@ test('teacher avatar consumes the welcome bubble through close, reopen and reloa
|
||||
await page.getByTestId('ai-module-option-programming').click();
|
||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||
const teacherButton = page.getByRole('button', { name: '与代码智能体聊天', exact: true });
|
||||
const invitation = page.getByTestId('teacher-invitation');
|
||||
const invitation = page.locator('[data-testid="teacher-welcomes"] .teacher-welcome-card[data-agent-id="teacher-code"]');
|
||||
const teacher = page.getByTestId('teacher-chat-panel');
|
||||
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
|
||||
await expect(invitation).toBeVisible();
|
||||
await teacherButton.click();
|
||||
await expect(teacher).toBeVisible();
|
||||
await expect(teacher.getByText('一起理解代码', { exact: true })).toBeVisible();
|
||||
@@ -2205,6 +2205,7 @@ test('teacher avatar consumes the welcome bubble through close, reopen and reloa
|
||||
await page.reload();
|
||||
await expect(teacherButton).toBeVisible();
|
||||
await expect(invitation).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '收起朋友的介绍' })).toBeVisible();
|
||||
expect((await readState(electronApp)).captured.filter(item => /\/(agent-topics|messages|teacher-check-in|prompt)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
|
||||
} finally { await releaseSnapshot(electronApp); }
|
||||
});
|
||||
@@ -2229,9 +2230,9 @@ test('teacher stays in the top-right header without legacy check-ins or taking o
|
||||
await expect(teacherButton.locator('img')).toHaveAttribute('src', /avatar-01/);
|
||||
await expect(page.getByRole('checkbox', { name: '智能体偶尔来看看' })).toHaveCount(0);
|
||||
await expect(page.getByTestId('teacher-companion')).toHaveCount(1);
|
||||
const invitation = companion.getByTestId('teacher-invitation');
|
||||
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
|
||||
await expect(invitation).toContainText('一起理解代码');
|
||||
const invitation = companion.locator('.teacher-welcome-card[data-agent-id="teacher-code"]');
|
||||
await expect(invitation).toBeVisible();
|
||||
await expect(invitation).toContainText('一起发现代码里的小奥秘,把想法变成作品。');
|
||||
await expect(invitation).toBeInViewport({ ratio: 1 });
|
||||
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0);
|
||||
await page.screenshot({ path: test.info().outputPath('teacher-entry-greeting.png') });
|
||||
@@ -2261,27 +2262,28 @@ test('teacher stays in the top-right header without legacy check-ins or taking o
|
||||
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');
|
||||
await expect(invitation).toBeVisible();
|
||||
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);
|
||||
await expect(invitation).toContainText('一起理解代码');
|
||||
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
|
||||
await expect(invitation).toContainText('一起发现代码里的小奥秘,把想法变成作品。');
|
||||
await expect(invitation).toBeVisible();
|
||||
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0);
|
||||
await expect(invitation).toBeInViewport({ ratio: 1 });
|
||||
const presence = companion.getByTestId('teacher-presence');
|
||||
await expect(presence.getByRole('button', { name: '打开这条智能体消息' })).toBeVisible();
|
||||
await expect(presence.getByRole('button', { name: '打开这条智能体消息' }).locator('img')).toHaveAttribute('src', await teacherButton.locator('img').getAttribute('src') as string);
|
||||
const presence = companion.getByTestId('teacher-welcomes');
|
||||
await expect(presence.getByRole('button', { name: '认识代码智能体' })).toBeVisible();
|
||||
await expect(presence.getByRole('button', { name: '认识代码智能体' }).locator('img')).toHaveAttribute('src', await teacherButton.locator('img').getAttribute('src') as string);
|
||||
const [invitationBounds, companionBounds, avatarBounds, presenceBounds, speakerBounds] = await Promise.all([
|
||||
invitation.boundingBox(), companion.boundingBox(), teacherButton.locator('img').boundingBox(), presence.boundingBox(), presence.getByRole('button', { name: '打开这条智能体消息' }).boundingBox(),
|
||||
invitation.boundingBox(), companion.boundingBox(), teacherButton.locator('img').boundingBox(), presence.boundingBox(), presence.getByRole('button', { name: '认识代码智能体' }).boundingBox(),
|
||||
]);
|
||||
expect(invitationBounds).not.toBeNull();
|
||||
expect(companionBounds).not.toBeNull();
|
||||
expect(avatarBounds).not.toBeNull();
|
||||
expect(invitationBounds!.y).toBeGreaterThanOrEqual(avatarBounds!.y + avatarBounds!.height);
|
||||
expect(Math.abs(presenceBounds!.x + presenceBounds!.width - companionBounds!.x - companionBounds!.width)).toBeLessThanOrEqual(2);
|
||||
expect(speakerBounds!.x - invitationBounds!.x - invitationBounds!.width).toBeLessThanOrEqual(10);
|
||||
expect(speakerBounds!.x).toBeGreaterThanOrEqual(presenceBounds!.x);
|
||||
expect(speakerBounds!.x + speakerBounds!.width).toBeLessThan(invitationBounds!.x + invitationBounds!.width);
|
||||
await expect(composer).toBeFocused();
|
||||
await expect(composer).toHaveValue('我还在写自己的想法');
|
||||
await expect(page.getByRole('tab', { name: '操作对话', exact: true })).toHaveAttribute('aria-selected', 'true');
|
||||
@@ -2303,7 +2305,7 @@ test('teacher stays in the top-right header without legacy check-ins or taking o
|
||||
await expect(composer).toHaveValue('我还在写自己的想法');
|
||||
await page.clock.fastForward(300_000);
|
||||
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0);
|
||||
await invitation.getByRole('button', { name: '和智能体聊聊' }).click();
|
||||
await invitation.getByRole('button', { name: '和代码智能体聊聊' }).click();
|
||||
await expect(invitation).toHaveCount(0);
|
||||
await expect(presence).toHaveCount(0);
|
||||
await expect(teacherButton).toBeVisible();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
|
||||
|
||||
let server: ViteDevServer;
|
||||
let baseUrl: string;
|
||||
test.use({ channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
|
||||
test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
|
||||
test.beforeAll(async () => {
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
@@ -35,6 +35,50 @@ test.beforeAll(async () => {
|
||||
});
|
||||
test.afterAll(async () => { await server?.close(); });
|
||||
|
||||
for (const width of [375, 768, 1280]) {
|
||||
test(`all teacher introductions fit at ${width}px and keep the native preview clear`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto(baseUrl + '?agents=3&welcomes');
|
||||
const group = page.getByTestId('teacher-welcomes');
|
||||
await expect(group.locator('.teacher-welcome-card')).toHaveCount(3);
|
||||
await expect(group).toBeInViewport({ ratio: 1 });
|
||||
for (const card of await group.locator('.teacher-welcome-card').all()) {
|
||||
await expect(card).toBeInViewport({ ratio: 1 });
|
||||
expect(await card.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
}
|
||||
await expect.poll(async () => {
|
||||
const [bubble, viewport] = await Promise.all([group.boundingBox(), page.getByTestId('agent-browser-viewport').boundingBox()]);
|
||||
return !!bubble && !!viewport && viewport.y >= bubble.y + bubble.height;
|
||||
}).toBe(true);
|
||||
await page.screenshot({ path: test.info().outputPath(`teacher-introductions-${width}.png`) });
|
||||
await group.getByRole('button', { name: '收起智能体2的介绍' }).click();
|
||||
await expect(group.locator('.teacher-welcome-card')).toHaveCount(2);
|
||||
await group.getByRole('button', { name: '和智能体3聊聊' }).click();
|
||||
await expect(page.getByRole('button', { name: '与智能体3聊天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(group).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '关闭智能体栏' }).click();
|
||||
await expect(group.locator('.teacher-welcome-card')).toHaveCount(1);
|
||||
await group.getByRole('button', { name: '先自己试试' }).click();
|
||||
await expect(group).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('many teacher introductions remain scrollable and honor reduced motion', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 520, height: 600 });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(baseUrl + '?agents=8&welcomes');
|
||||
const group = page.getByTestId('teacher-welcomes');
|
||||
await expect(group).toBeInViewport({ ratio: 1 });
|
||||
await expect(group.locator('.teacher-welcome-card')).toHaveCount(8);
|
||||
await expect(group.locator('.teacher-welcome-card').first()).toHaveCSS('animation-name', 'none');
|
||||
const last = group.getByRole('button', { name: '和智能体8聊聊' });
|
||||
await last.scrollIntoViewIfNeeded();
|
||||
await expect(last).toBeInViewport({ ratio: 1 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(520);
|
||||
await group.getByRole('button', { name: '先自己试试' }).click();
|
||||
await expect(group).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const width of [520, 1000]) {
|
||||
test(`distributed agent tabs scroll inside the title bar at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
|
||||
@@ -7,8 +7,9 @@ import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
|
||||
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherCatalog, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
|
||||
|
||||
const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn(), conversation: vi.fn(), sendConversation: vi.fn(), seenConversation: vi.fn() }));
|
||||
const identity = vi.hoisted(() => ({ id: 'kid' }));
|
||||
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project }));
|
||||
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid' } }) }));
|
||||
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: identity.id } }) }));
|
||||
const definition: TeacherDefinition = {
|
||||
schema_version: 1, teacher_id: 'coding-teacher', name: '运营配置的智能体', description: '', avatar_id: 'avatar-06', welcome_message: '', suggested_questions: [], system_prompt: '指导', skills: [],
|
||||
model: { model_id: 'model', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
|
||||
@@ -37,6 +38,7 @@ async function advance(ms = TEACHER_CHECK_IN_INTERVAL_MS) {
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear();
|
||||
identity.id = 'kid';
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
@@ -52,6 +54,83 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe('individual teacher introductions', () => {
|
||||
const agents = ['方法老师', '体验老师', '故事老师'].map((name, index) => ({
|
||||
teacher_id: `intro-${index}`, version: 1, is_default: index === 0,
|
||||
definition: { ...definition, config_id: `intro-${index}`, name, description: `${name}的自我介绍`, welcome_message: `${name}的聊天欢迎语` },
|
||||
}));
|
||||
|
||||
it('introduces every delivered teacher independently of the old shared welcome dismissal without sending requests', async () => {
|
||||
localStorage.setItem('makelore-teacher-presence:kid:welcome-dismissed', 'true');
|
||||
api.catalog.mockResolvedValue({ items: agents });
|
||||
await mount();
|
||||
const group = screen.getByTestId('teacher-welcomes');
|
||||
for (const agent of agents) expect(within(group).getByText(agent.definition.description)).toBeVisible();
|
||||
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起体验老师的介绍' })));
|
||||
expect(within(group).queryByText('体验老师的自我介绍')).not.toBeInTheDocument();
|
||||
expect(within(group).getByText('方法老师的自我介绍')).toBeVisible();
|
||||
expect(within(group).getByText('故事老师的自我介绍')).toBeVisible();
|
||||
await advance();
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(api.sendConversation).not.toHaveBeenCalled();
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
expect(api.seenConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps dismissal across reloads for this account/project while leaving other projects and accounts independent', async () => {
|
||||
api.catalog.mockResolvedValue({ items: agents });
|
||||
const first = await mount();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起方法老师的介绍' })));
|
||||
first.unmount();
|
||||
const next = await mount();
|
||||
expect(screen.queryByRole('button', { name: '收起方法老师的介绍' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '收起体验老师的介绍' })).toBeVisible();
|
||||
await act(async () => next.rerender(<Harness projectId="other" />));
|
||||
expect(screen.getByRole('button', { name: '收起方法老师的介绍' })).toBeVisible();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '先自己试试' })));
|
||||
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
|
||||
await act(async () => next.rerender(<Harness />));
|
||||
expect(screen.queryByRole('button', { name: '收起方法老师的介绍' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '收起体验老师的介绍' })).toBeVisible();
|
||||
identity.id = 'another-kid';
|
||||
await act(async () => next.rerender(<Harness />));
|
||||
expect(screen.getByRole('button', { name: '收起方法老师的介绍' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('opens the clicked teacher and consumes only their introduction, preserving the others for later', async () => {
|
||||
api.catalog.mockResolvedValue({ items: agents });
|
||||
await mount();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '和故事老师聊聊' })));
|
||||
expect(screen.getByRole('button', { name: '与故事老师聊天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByTestId('teacher-chat-panel')).toHaveTextContent('故事老师的聊天欢迎语');
|
||||
expect(screen.getByTestId('teacher-chat-panel')).not.toHaveTextContent('故事老师的自我介绍');
|
||||
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与故事老师聊天' })));
|
||||
expect(screen.queryByRole('button', { name: '收起故事老师的介绍' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '收起方法老师的介绍' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '收起体验老师的介绍' })).toBeVisible();
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.sendConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('introduces only newly delivered teachers after dismiss-all, without resetting seen replies or existing receipts on refresh', async () => {
|
||||
localStorage.setItem('makelore-teacher-presence:kid:seen', JSON.stringify(['keep-seen']));
|
||||
api.catalog.mockResolvedValue({ items: agents });
|
||||
await mount();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '先自己试试' })));
|
||||
const added = { ...agents[0], teacher_id: 'new', definition: { ...agents[0].definition, config_id: 'new', name: '新老师', description: '', welcome_message: '新老师的云端欢迎语' } };
|
||||
api.catalog.mockResolvedValue({ items: [...agents.map(agent => ({ ...agent, version: 2 })), added] });
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
||||
expect(screen.getByTestId('teacher-welcomes')).toHaveTextContent('新老师的云端欢迎语');
|
||||
for (const agent of agents) expect(screen.queryByRole('button', { name: `收起${agent.definition.name}的介绍` })).not.toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem('makelore-teacher-presence:kid:seen')!)).toEqual(['keep-seen']);
|
||||
expect(api.sendConversation).not.toHaveBeenCalled();
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual agent refresh', () => {
|
||||
it('refreshes while busy and composing, shares one request, and sends through the same agent after publication updates', async () => {
|
||||
const original = { teacher_id: 'refresh-first-method', version: 3, is_default: true, definition: { ...definition, config_id: 'refresh-first-method', name: '方法老师', welcome_message: '一起想想办法' } };
|
||||
|
||||
Reference in New Issue
Block a user