diff --git a/.project-docs/30-worklog/tasks/20260928-teacher-welcome-diagnosis-4fb809c2.md b/.project-docs/30-worklog/tasks/20260928-teacher-welcome-diagnosis-4fb809c2.md new file mode 100644 index 00000000..0f92c2d0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-teacher-welcome-diagnosis-4fb809c2.md @@ -0,0 +1,50 @@ +# Task: Explain the first-click teacher welcome bubble + +## Identity + +- Task ID: 20260928-teacher-welcome-diagnosis-4fb809c2 +- Mode: Feature +- Branch: codex/20260928-teacher-welcome-diagnosis-4fb809c2-teacher-welcome-diagnosis +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-teacher-welcome-diagnosis-4fb809c2 +- Base commit: 4495345fb0a9ea1abef8e603629ce9b51d73eb05 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Diagnose and fix the first-click teacher welcome bubble at baseline 4495345. Entering consultation consumes the static welcome and preserves true unread proactive messages. + +## Intent And Constraints + +- User first asked why the screenshot bubble appears, then explicitly requested the proposed fix. +- Concurrent and Planning Gates passed in this owned checkout; task identity, branch, absolute path and base verified on resume. Relevant project memory and peer scopes reviewed. Adjacent cleanup and truncation tasks remain isolated. +- Preserve published greeting content inside the consultation panel, account/project scope, real unread check-ins, cloud configuration, model requests and conversation state. +- No main merge, live trial restart, cloud publication or paid model request in this task. + +## Outcome + +- Cause: welcome_message displayed after config load until explicit dismissal; avatar entry only opened the panel, leaving the welcome bubble visible and changing its CTA to 看看智能体说的. +- The hook now hides static welcome immediately when consultation is open, then records welcome-dismissed for the current account/project. This also handles entering before async config completes, closing before config completes, same-agent selection and reload. +- The effect runs after scope initialization and changes only welcomeDismissed. Real invitations and seen receipts are untouched; a real check-in still requires explicit viewing/dismissal. +- Updated current README and focused unit/Electron coverage. Independent review found no actionable issue. + +## Verification + +- New unit tests first reproduced three failures on the prior source; unread check-in preservation already passed. +- corepack pnpm 10.33.4: teacher-companion.test.tsx, 33 passed. +- pnpm run typecheck, scoped ESLint, pnpm run build:vite and git diff --check passed. +- Electron welcome test passed: avatar entry, close/reopen, renderer reload, panel greeting retained, no model/topic POST. +- Electron proactive test passed: entry/close does not consume unread message; explicit CTA consumes it, original focus/draft/layout/timer assertions retained. Initial modified test had an invalid remount-focus expectation when opening an already-open panel; restored its original close-then-CTA sequence and reran successfully. Final total: 2 relevant Electron scenarios passed. +- Verification uses the existing mocked Host fixture with isolated test profiles; no real provider or trial application updated. + +## Follow-ups + +- Integrate this small source change with the current main/trial version through its owning integration task. Build outputs, test profiles and dependency symlink are not committed. + +## Promotion Candidates + +- Target: canonical classroom interaction/current-state docs during an integration task. +- Proposal: entering consultation consumes and persists the static welcome per account/project; actual unread proactive messages remain until explicitly viewed/dismissed. +- Evidence: explicit user request, hook change, 33 unit tests and two Electron scenarios. +- Future impact: prevents redundant first-entry invitations without hiding real messages. +- Semantic conflicts: supersedes the older welcome-until-manual-dismissal behavior only. No further human confirmation needed for this accepted behavior; canonical promotion remains integration-only. diff --git a/README.md b/README.md index 4c7520e9..390f9d18 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 鼠标悬停或键盘聚焦顶部智能体入口时,以奶油色圆角卡片显示该智能体的发布介绍;离开、Escape 或点击入口后收起,没有介绍则不显示空卡片。咨询栏不再显示重复的头像、名称标题条,也不显示介绍及新话题、展开、关闭三个图标;老师身份保留在顶部入口及消息署名中。智能体生成的追加问题以淡蓝、奶油黄、淡紫卡片依次承载,正文 16px、行高 1.8,保留整卡点击发送与忙碌时禁用。回复下方不再显示重复的输入引导或省略早期消息条数;输入框提示为“和老师聊聊”。再次点击当前入口或在咨询栏按 Escape 可收起,切换其他入口仍直接打开对应讨论;拖动边缘调宽保留,已下发智能体仅保留只读旧记录入口。悬浮介绍不会创建话题、调用模型或确认未读消息,作品原生预览同时避让介绍卡和真实主动气泡。 - 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 - 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 -- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 +- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;点击智能体进入咨询时立即收起欢迎语,并按账号/项目记住已查看,关闭、重新打开或刷新后不重复弹出;主动收起也保留同样记录。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 - 智能体支持自由提问;输入框为空时,底部显示暖黄色小按钮“继续看看👀”,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,程序说明学生主动求助且尚未提出具体问题,智能体结合可用上下文按云端配置回应。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接;失败或中断后,可点暖黄色小按钮“继续看看👀”重试。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 - 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。 - 客户端只提供入口事实、可用上下文和界面协议。讨论切入点、教学方式、是否提问、正文长度、前缀和表达风格由所选智能体的云端配置决定;不在“继续看看👀”、引导开口或后台检查时附加教学策略。当前新回复允许 0–3 条快捷回复,没有时用空数组,不要求凑满。 diff --git a/src/pages/Chat/use-teacher-companion.ts b/src/pages/Chat/use-teacher-companion.ts index 44302560..2f356390 100644 --- a/src/pages/Chat/use-teacher-companion.ts +++ b/src/pages/Chat/use-teacher-companion.ts @@ -286,6 +286,13 @@ export function useTeacherCompanion(options: Options) { return () => { alive = false; window.clearInterval(timer); window.removeEventListener('focus', refresh); }; }, [account, state.agents]); + useEffect(() => { + if (!options.teacherOpen || !options.projectId) return; + // Entering consultation consumes the greeting, but leaves real invitations unread. + writeLocal(storageKey + ':welcome-dismissed', true); + setState((current) => current.welcomeDismissed ? current : { ...current, welcomeDismissed: true }); + }, [options.teacherOpen, options.projectId, storageKey]); + const topic = stateScope.current === scope ? state.topic : null; const topicId = topic?.id; const topicProjectId = topic?.projectId; @@ -315,7 +322,7 @@ export function useTeacherCompanion(options: Options) { const selectedAgentId = visibleState.selectedAgent?.teacher_id ?? selectedDefinition?.config_id; const selectedAgent = visibleState.selectedAgent === null ? undefined : visibleState.agents.find((item) => item.teacher_id === selectedAgentId) ?? visibleState.selectedAgent; - const welcome = topic?.role !== 'friend' && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed + const welcome = topic?.role !== 'friend' && !options.teacherOpen && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed ? selectedDefinition?.welcome_message.trim() || null : null; return { unreadAgentIds, diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index f7a93ab8..58b94c8b 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -2174,6 +2174,41 @@ test('settled prompt releases the composer without a client receipt and keeps th } finally { await releaseSnapshot(electronApp); } }); +test('teacher avatar consumes the welcome bubble through close, reopen and reload without a model request', async ({ launchElectronApp }) => { + const electronApp = await launchElectronApp({ skipSetup: true }); + let page = await getStableWindow(electronApp); + const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string })); + await installCodingFirstChatHost(electronApp, connection, true); + await settleSnapshot(electronApp); + await disableCodingEventSource(page); + try { + await page.reload(); page = await getStableWindow(electronApp); + 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 teacher = page.getByTestId('teacher-chat-panel'); + await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome'); + await teacherButton.click(); + await expect(teacher).toBeVisible(); + await expect(teacher.getByText('一起理解代码', { exact: true })).toBeVisible(); + await expect(invitation).toHaveCount(0); + await page.screenshot({ path: test.info().outputPath('teacher-avatar-consumed-welcome.png') }); + await teacherButton.click(); + await expect(teacher).toHaveCount(0); + await expect(invitation).toHaveCount(0); + await teacherButton.click(); + await expect(teacher).toBeVisible(); + await expect(invitation).toHaveCount(0); + await teacherButton.click(); + await expect(teacher).toHaveCount(0); + await page.reload(); + await expect(teacherButton).toBeVisible(); + await expect(invitation).toHaveCount(0); + 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); } +}); + test('teacher stays in the top-right header without legacy check-ins or taking over the student draft', async ({ launchElectronApp }) => { const electronApp = await launchElectronApp({ skipSetup: true }); let page = await getStableWindow(electronApp); diff --git a/tests/unit/teacher-companion.test.tsx b/tests/unit/teacher-companion.test.tsx index 720534f5..bcb6b1ea 100644 --- a/tests/unit/teacher-companion.test.tsx +++ b/tests/unit/teacher-companion.test.tsx @@ -468,6 +468,73 @@ describe('teacher presence and periodic guidance', () => { expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); }); + it('consumes the static greeting when consultation opens through the avatar and remembers it for the project', async () => { + const greeting = '有什么编程问题想一起讨论?'; + api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: greeting } }); + const view = await mount(); + expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'welcome'); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + expect(within(screen.getByTestId('teacher-chat-panel')).getByText(greeting)).toBeVisible(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + expect(screen.getByTestId('teacher-chat-panel')).toBeVisible(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + view.unmount(); + const next = await mount(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + await act(async () => next.rerender()); + expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'welcome'); + expect(api.create).not.toHaveBeenCalled(); + expect(api.send).not.toHaveBeenCalled(); + expect(api.checkIn).not.toHaveBeenCalled(); + }); + + it.each([false, true])('consumes the static greeting when consultation opens before config loads (closed before load: %s)', async (closeBeforeLoad) => { + const config = { enabled: true, definition: { ...definition, welcome_message: '欢迎一起创作' } }; + let resolve!: (value: typeof config) => void; + api.config.mockImplementationOnce(() => new Promise((done) => { resolve = done; })); + api.config.mockResolvedValue(config); + const view = await mount(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + if (closeBeforeLoad) { + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + } + await act(async () => resolve(config)); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + if (!closeBeforeLoad) { + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + } + expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + view.unmount(); + await mount(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + expect(api.create).not.toHaveBeenCalled(); + expect(api.send).not.toHaveBeenCalled(); + expect(api.checkIn).not.toHaveBeenCalled(); + }); + + it('consumes only the static greeting on avatar entry and leaves a real unread check-in until explicitly viewed', async () => { + api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '欢迎一起创作' } }); + api.list.mockResolvedValue({ items: [{ id: 'topic-project' }], lastSelectedTopicId: 'topic-project' }); + api.read.mockResolvedValue(checkInTopic()); + await mount(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'check-in'); + expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜'); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '看看智能体说的' }))); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + expect(api.create).not.toHaveBeenCalled(); + expect(api.send).not.toHaveBeenCalled(); + expect(api.checkIn).not.toHaveBeenCalled(); + }); + it('keeps the elapsed interval when the Renderer reloads instead of starting another five-minute wait', async () => { const first = await mount(); await advance(4 * 60_000);