feat(ui): introduce each teacher with an individual welcome bubble

This commit is contained in:
鲨鱼辣椒
2026-09-29 14:02:50 +08:00
parent e4b99d5e32
commit c82b1a43be
9 changed files with 253 additions and 22 deletions

View File

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

View File

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

View File

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