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 260885c8..f9607876 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 中止边界。 - 智能体同时支持自由提问与输入框底部的提示词按钮“帮我看看”:学生点击后才结合当前操作对话与咨询历史生成 2–3 个可聊的问题。点击生成的问题会主动开始讨论;“我也说不清,你带我看看”让智能体从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 - 智能体讨论采用“上方固定整理内容、下方独立滚动对话、底部原有输入框”的布局。普通回答可带直接发送的引导问题;想法板、结构图、流程/条件图和逐项对照由同一次模型回复提供结构化数据。先邀请学生“用这个一起想”,进入后程序锁定信息结构,智能体随讨论更新同一份内容;解释问题可仅回复文字。节点点击只选择讨论焦点,对照里的“聊聊这一点”直接发问,都保留输入草稿。 - 想法板区分已留下、智能体建议和暂放内容,可由学生采纳、暂放、选择先想哪项。“把想法理一理”明确转换为智能体归纳的结构图,并可“回去补充想法”。“只聊天”暂停整理,“先这些”暂时结束并保留未确定内容,“接着改”恢复原工具;这些操作只影响智能体讨论,不创建项目分支、不执行作品修改。状态、版本和历史快照按当前账号/项目/话题保存。 diff --git a/src/pages/Chat/use-teacher-companion.ts b/src/pages/Chat/use-teacher-companion.ts index 4488965d..d378be0e 100644 --- a/src/pages/Chat/use-teacher-companion.ts +++ b/src/pages/Chat/use-teacher-companion.ts @@ -234,6 +234,13 @@ export function useTeacherCompanion(options: Options) { }; }, [account, options.projectId, scope, storageKey, noteTopic]); + 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; @@ -262,7 +269,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 { ...visibleState, diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index 574e68a0..f4474d2e 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -2028,6 +2028,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 and proactively checks in without taking over the student draft', async ({ launchElectronApp }) => { const electronApp = await launchElectronApp({ skipSetup: true }); let page = await getStableWindow(electronApp); @@ -2121,6 +2156,12 @@ test('teacher stays in the top-right header and proactively checks in without ta await expect(composer).toHaveValue('我还在写自己的想法'); await page.clock.fastForward(300_000); expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(1); + await teacherButton.click(); + await expect(page.getByTestId('teacher-chat-panel')).toBeVisible(); + await expect(invitation).toHaveAttribute('data-bubble-kind', 'check-in'); + await teacherButton.click(); + await expect(page.getByTestId('teacher-chat-panel')).toHaveCount(0); + await expect(invitation).toHaveAttribute('data-bubble-kind', 'check-in'); await invitation.getByRole('button', { name: '和智能体聊聊' }).click(); await expect(invitation).toHaveCount(0); await expect(presence).toHaveCount(0); diff --git a/tests/unit/teacher-companion.test.tsx b/tests/unit/teacher-companion.test.tsx index 0b6514f6..56fcae46 100644 --- a/tests/unit/teacher-companion.test.tsx +++ b/tests/unit/teacher-companion.test.tsx @@ -423,6 +423,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);