Merge welcome dismissal while preserving continuous conversation entry
This commit is contained in:
@@ -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.
|
||||
@@ -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 条快捷回复,没有时用空数组,不要求凑满。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(<Harness projectId="other" />));
|
||||
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<typeof config>((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);
|
||||
|
||||
Reference in New Issue
Block a user