diff --git a/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md b/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md index 9f5837bd..74151afe 100644 --- a/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md +++ b/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md @@ -141,3 +141,20 @@ - Actual live DOM now has 2 delivered teacher tabs and 2 matching introduction cards (architecture teacher and Alice); screenshot inspected and cropped without activating the window. Window remains visible/unfocused. Focus monitor observed Codex only, and temporary inspector was closed. No cloud publication, live model question, formal install/restart or remote push; source change is committed locally. - Required documentation structure passes; inherited drift check still reports only the same five imported task records relative to the original merge base. No foreign task record or canonical document edited. Retain this checkout because the trial runs its build. - Promotion candidate: canonical classroom interaction/current-state should replace catalog-wide single welcome with project-scoped per-teacher introductory receipts and independent group presentation. Evidence: user's approved design, source/tests and live two-teacher capture. This supersedes the older shared welcome-dismissal UX for delivered catalogs only; legacy/check-in semantics remain compatible. No additional product decision is needed; canonical promotion requires a separate integration gate. + + +## Follow-up: Collapse Introductions After Three Operation Rounds + +- User requests introductions immediately on project entry, left visible when untouched during the first three operation-Agent rounds, then automatically collapsed; hover introductions remain available. Continue the same owned UI/trial task without subagents or foreground activation. +- Project Context Loaded: official start/status confirm task ID, feature mode, exact worktree/branch and base above; own record and active peer scopes read, unchanged entry/positioning/teacher/runtime context reused. Formal-app/cloud-policy and unfinished language-skill peers have no overlap with this presentation-only change. Gate Passed. +- Plan: observe successful operation prompt completions from the existing conversation store, count each run once after that teacher's introduction starts, persist at most three receipts per account/project/teacher, and dismiss only introduction cards after the third round. Historical snapshots, tools/retries, failures/aborts, teacher conversations and other projects must not advance the count. Preserve individual dismissal and give newly delivered teachers their own window; do not mutate runtime, history, unread suggestions, drafts or model context. Add focused state/UI and Electron fixture regressions, run required checks/build, and safely refresh the existing temporary Renderer. No cloud publication, formal-app update or remote push. + + +### Three-Round Introduction Outcome And Verification + +- Introduction cards appear on entry as before and now dismiss after the third successful operation-Agent prompt completion that started after their introduction. The observer reads the existing conversation store across operation chats in the active project, deduplicates by conversation/run identity, and persists only the introduction start time and up to three run receipts per account/project/teacher. Refresh resumes progress; newly delivered teachers receive their own window. Tools/retries, teacher messages, failed/aborted runs, manual compaction, old history and other projects do not count. No runtime, submission, history, prompt, cloud or billing changes. +- Automatic dismissal uses only the per-teacher introduction receipt, preserving unread check-ins, ordinary teacher chat, hover descriptions and existing manual dismissal. Returning to a project does not reintroduce previously dismissed teachers. README reflects the new behavior. +- Five new real-store/UI regressions cover the exact third-completion boundary, duplicate snapshots, streaming/retry/failure/abort/history/compaction exclusions, reload and source switches, project/account/new-teacher isolation, teacher conversations and hover/unread preservation. Focused 3 suites / 112 tests pass. Full test command passes 263 suites / 2651 tests with 3 unchanged skips, followed by the pressure stage 1/1. Typecheck, full lint (0 errors / 8 unchanged warnings), build:vite and whitespace checks pass. All 13 headless preview/layout cases pass; Electron fixture includes the three-round/hover/reload regression and lists 20 cases, but native foreground E2E was not launched. +- Refreshed the existing temporary Renderer to `index-C18pxj_y.js` without restarting Main PID 62668 or taking focus. Backups and evidence: trial `teacher-introduction-rounds-20260929/`. Before reload there were no active workers/stop controls or nonempty inputs. Verified the loaded new asset, both delivered teacher cards, unchanged authenticated identity/route and all 13 saved teacher drafts. Screenshot reviewed; one initial background capture failed transiently with UnknownVizError, the next read/capture succeeded. No live three-turn model requests were made; counting behavior is verified by the regressions. +- NSWorkspace monitor observed Codex only; trial remains visible/unfocused. Formal app and cloud configuration untouched, no remote push or subagent. Required document structure passes; drift remains the same five unchanged imported source task records relative to the original merge base. Retain this checkout for the live trial. +- Promotion candidate: per-teacher introduction receipts now include a three-successful-operation-round window, persistent over page refresh and independent of teacher discussion. No canonical documents edited in this feature task. diff --git a/README.md b/README.md index 7cbe4afd..fd4eacfe 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 中止边界。 +- 进入项目时,每位已下发智能体各显示一个浅色自我介绍气泡,优先使用云端介绍,缺省使用发布欢迎语;不调用模型、不声称已检查项目。气泡进入项目即依次出现,不点击时保留到操作 Agent 第 3 轮完整回复结束后自动收起,也可分别关闭或用“先自己试试”整组收起;按账号、项目、智能体保存计数及已介绍状态,刷新与操作对话切换延续进度。旧历史、老师咨询、工具调用、重试中间状态、失败/中止和其他项目不计入,新增智能体单独补上并从零计数。点击气泡或头像进入对应咨询,只记住这一位已介绍;咨询期间收起介绍组,关闭后其余未查看介绍仍可见,悬停头像仍可查看完整介绍。没有下发目录的旧入口保留原欢迎语展示兼容。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 - 智能体支持自由提问;输入框为空时,底部显示暖黄色小按钮“不知道问什么,你先帮我看看👀”,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,程序说明学生主动求助且尚未提出具体问题,智能体结合可用上下文按云端配置回应。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接;失败或中断后,可点暖黄色小按钮“不知道问什么,你先帮我看看👀”重试。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 - 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。 - 客户端只提供入口事实、可用上下文和界面协议。讨论切入点、教学方式、是否提问、正文长度、前缀和表达风格由所选智能体的云端配置决定;不在“不知道问什么,你先帮我看看👀”、引导开口或后台检查时附加教学策略。当前新回复允许 0–3 条快捷回复,没有时用空数组,不要求凑满。 diff --git a/src/pages/Chat/use-teacher-companion.ts b/src/pages/Chat/use-teacher-companion.ts index d95c4386..1e83407a 100644 --- a/src/pages/Chat/use-teacher-companion.ts +++ b/src/pages/Chat/use-teacher-companion.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { teacherApi, teacherTopicsPath, type TeacherCatalogState } from '@/lib/coding-teacher'; import { useAuthStore } from '@/stores/auth'; import { AppError } from '@/lib/error-model'; +import { useTeacherIntroductionRounds } from './use-teacher-introduction-rounds'; import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherCheckInInput, @@ -346,6 +347,9 @@ export function useTeacherCompanion(options: Options) { // Opening one teacher consumes only that teacher's introduction, never their unread replies. if (options.teacherOpen && selectedAgent?.teacher_id) dismissWelcome(selectedAgent.teacher_id); }, [options.teacherOpen, selectedAgent?.teacher_id, dismissWelcome]); + useTeacherIntroductionRounds(introductionKey, options.projectId, + visibleState.agents.filter(agent => !introducedIds.includes(agent.teacher_id)).map(agent => agent.teacher_id), + dismissIntroductions); const welcomes = options.projectId && !options.teacherOpen && !visibleState.loading ? visibleState.agents.flatMap((agent, index) => introducedIds.includes(agent.teacher_id) ? [] : [{ agentId: agent.teacher_id, diff --git a/src/pages/Chat/use-teacher-introduction-rounds.ts b/src/pages/Chat/use-teacher-introduction-rounds.ts new file mode 100644 index 00000000..161219c3 --- /dev/null +++ b/src/pages/Chat/use-teacher-introduction-rounds.ts @@ -0,0 +1,67 @@ +import { useEffect } from 'react'; +import { codingConversationStore, type CodingConversationStoreState } from '@/stores/coding-conversations'; + +interface IntroductionProgress { + since: number; + runs: string[]; +} + +function readProgress(key: string): Record { + try { + const value: unknown = JSON.parse(localStorage.getItem(key) ?? '{}'); + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries(Object.entries(value).filter(([, progress]) => ( + progress && typeof progress.since === 'number' && Number.isFinite(progress.since) + && Array.isArray(progress.runs) && progress.runs.length <= 3 + && progress.runs.every((run: unknown) => typeof run === 'string') + ))); + } catch { return {}; } +} + +/** Presentation receipts only: observe Main's settled runs, never dispatch or edit a conversation. */ +export function useTeacherIntroductionRounds( + introductionKey: string | null, + projectId: string | null, + pendingAgentIds: string[], + dismiss: (ids: string[]) => void, +) { + const agentIdsKey = JSON.stringify(pendingAgentIds); + useEffect(() => { + const agentIds: string[] = JSON.parse(agentIdsKey); + if (!introductionKey || !projectId || !agentIds.length) return; + const key = `${introductionKey}:rounds`; + const saved = readProgress(key); + const progress = Object.fromEntries(agentIds.map(id => [id, saved[id] ?? { since: Date.now(), runs: [] }])); + const persist = () => { + try { localStorage.setItem(key, JSON.stringify(progress)); } catch { /* Keep counting in memory. */ } + }; + persist(); + const observe = (state: CodingConversationStoreState) => { + let changed = false; + for (const entry of Object.values(state.entriesByConversationId)) { + const snapshot = entry.reducer.snapshot; + if (entry.loadState !== 'live' || entry.reducer.invalidation || snapshot?.conversation.projectId !== projectId) continue; + const run = snapshot.run; + // A successful top-level prompt is one round, regardless of its tool calls or retries. + if (run.status !== 'idle' || run.terminalReason !== 'completed' || run.mode !== 'prompt' + || !run.runId || run.startedAt === undefined) continue; + const runKey = JSON.stringify([snapshot.conversation.id, run.runId]); + for (const item of Object.values(progress)) { + // Persist the starting time so loading old history cannot consume introductions. + if (item.runs.length >= 3 || run.startedAt < item.since || item.runs.includes(runKey)) continue; + item.runs.push(runKey); + changed = true; + } + } + if (changed) persist(); + const finished = agentIds.filter(id => progress[id].runs.length >= 3); + if (finished.length) dismiss(finished); + }; + // Include a completion delivered during a page reload, and observe all project conversations + // so switching the selected operation chat cannot lose or double-count a completion. + observe(codingConversationStore.getState()); + return codingConversationStore.subscribe((state, previous) => { + if (state.entriesByConversationId !== previous.entriesByConversationId) observe(state); + }); + }, [introductionKey, projectId, agentIdsKey, dismiss]); +} diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index 2d88f823..d2a48c79 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -2174,6 +2174,46 @@ test('settled prompt releases the composer without a client receipt and keeps th } finally { await releaseSnapshot(electronApp); } }); +test('teacher introductions collapse after three operation replies and remain available on hover', 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 introductions = page.getByTestId('teacher-welcomes'); + await expect(introductions.locator('.teacher-welcome-card')).toHaveCount(2); + const startedAt = await page.evaluate(() => Date.now()); + for (let round = 1; round <= 3; round++) { + for (const status of ['running', 'idle'] as const) { + const seq = round * 2 + (status === 'idle' ? 1 : 0); + const snapshot = { + schemaVersion: 1, + conversation: { id: 'conversation-pi-first-chat', projectId: 'project-pi-first-chat', agentId: 'builder', title: '新对话', model: { model: { accountId: 'account-e2e', modelId: 'model-a', thinkingLevel: 'off' }, modelResolution: 'resolved' } }, + nodes: [], run: { status, mode: 'prompt', runId: `intro-round-${round}`, startedAt, + ...(status === 'idle' ? { terminalReason: 'completed', settledAt: startedAt + round } : {}) }, + worker: { status: 'ready', generation: 1 }, cursor: { workerGeneration: 1, seq }, + queue: { items: [] }, context: { usedTokens: 0, contextWindow: 4096, compaction: 'idle' }, pendingInteractions: [], + }; + await emitCodingEvent(page, 'snapshot', { type: 'snapshot', conversationId: snapshot.conversation.id, workerGeneration: 1, seq, snapshot }); + if (round < 3 || status === 'running') await expect(introductions).toBeVisible(); + } + } + await expect(introductions).toHaveCount(0); + await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).hover(); + await expect(page.getByTestId('teacher-introduction')).toContainText('一起发现代码里的小奥秘'); + await page.screenshot({ path: test.info().outputPath('teacher-introduction-after-three-rounds.png') }); + await page.reload(); + await expect(page.getByRole('button', { name: '与代码智能体聊天', exact: true })).toBeVisible(); + await expect(introductions).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 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); diff --git a/tests/unit/teacher-companion.test.tsx b/tests/unit/teacher-companion.test.tsx index d070da70..989f8347 100644 --- a/tests/unit/teacher-companion.test.tsx +++ b/tests/unit/teacher-companion.test.tsx @@ -4,6 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useTeacherCompanion } from '@/pages/Chat/use-teacher-companion'; import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion'; import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel'; +import { codingConversationStore } from '@/stores/coding-conversations'; +import type { ConversationRunState } from '@/types/coding-conversation'; +import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures'; 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() })); @@ -38,6 +41,7 @@ async function advance(ms = TEACHER_CHECK_IN_INTERVAL_MS) { } beforeEach(() => { vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear(); + codingConversationStore.setState({ entriesByConversationId: {}, summariesByConversationId: {} }); identity.id = 'kid'; vi.stubGlobal('ResizeObserver', class { observe() {} @@ -60,6 +64,130 @@ describe('individual teacher introductions', () => { definition: { ...definition, config_id: `intro-${index}`, name, description: `${name}的自我介绍`, welcome_message: `${name}的聊天欢迎语` }, })); + async function operation(runId: string, overrides: Partial = {}, projectId = 'project', sourceId = 'source') { + const snapshot = createProductSnapshot(sourceId); + snapshot.conversation.projectId = projectId; + snapshot.run = { status: 'idle', mode: 'prompt', runId, startedAt: Date.now(), settledAt: Date.now(), terminalReason: 'completed', ...overrides }; + snapshot.cursor.seq = (codingConversationStore.getState().entriesByConversationId[sourceId]?.reducer.snapshot?.cursor.seq ?? 0) + 1; + await act(async () => codingConversationStore.getState().applySnapshotEvent({ + type: 'snapshot', conversationId: sourceId, workerGeneration: 1, seq: snapshot.cursor.seq, snapshot, + })); + } + + it('leaves introductions until the third completed operation round and preserves hover and unread receipts', async () => { + api.catalog.mockResolvedValue({ items: agents }); + localStorage.setItem('makelore-teacher-presence:kid:seen', JSON.stringify(['keep-seen'])); + await mount(); + await operation('one'); + await operation('one'); // A duplicate/recovered completion is still the same round. + await operation('two'); + expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3); + await operation('three', { status: 'running', terminalReason: undefined }); + expect(screen.getByTestId('teacher-welcomes')).toBeVisible(); + await operation('three'); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + fireEvent.pointerEnter(screen.getByRole('button', { name: '与方法老师聊天' }), { pointerType: 'mouse' }); + expect(screen.getByRole('tooltip')).toHaveTextContent('方法老师的自我介绍'); + expect(JSON.parse(localStorage.getItem('makelore-teacher-presence:kid:seen')!)).toEqual(['keep-seen']); + expect(api.sendConversation).not.toHaveBeenCalled(); + expect(api.checkIn).not.toHaveBeenCalled(); + expect(api.seenConversation).not.toHaveBeenCalled(); + }); + + it('ignores historical, failed, aborted, tool/retry, compaction and other-project activity', async () => { + api.catalog.mockResolvedValue({ items: agents }); + await mount(); + for (let i = 0; i < 3; i++) { + await operation(`history-${i}`, { startedAt: Date.now() - 60_000 }); + await operation(`failed-${i}`, { status: 'error', terminalReason: 'failed' }); + await operation(`aborted-${i}`, { terminalReason: 'aborted' }); + await operation(`retry-${i}`, { status: 'retrying', terminalReason: undefined }); + await operation(`tools-${i}`, { status: 'running', terminalReason: undefined }); + await operation(`compaction-${i}`, { mode: undefined }); + await operation(`other-project-${i}`, {}, 'other'); + } + await operation('one'); + await operation('two'); + expect(screen.getByTestId('teacher-welcomes')).toBeVisible(); + await operation('three'); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + }); + + it('continues the count across reload and operation-chat switches, including a completion during reload', async () => { + api.catalog.mockResolvedValue({ items: agents }); + const first = await mount(); + await operation('same-run-id'); + await act(async () => first.rerender()); + await operation('same-run-id', {}, 'project', 'second-source'); + expect(screen.getByTestId('teacher-welcomes')).toBeVisible(); + first.unmount(); + const second = await mount({ sourceId: 'second-source' }); + expect(screen.getByTestId('teacher-welcomes')).toBeVisible(); + second.unmount(); + await operation('third-during-reload', {}, 'project', 'source'); + const third = await mount(); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + third.unmount(); + await mount(); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + }); + + it('keeps round windows independent for projects/accounts and newly delivered teachers', async () => { + api.catalog.mockResolvedValue({ items: agents }); + const view = await mount(); + await operation('one'); + await operation('two'); + await advance(1); + await act(async () => view.rerender()); + expect(screen.getByTestId('teacher-welcomes')).toBeVisible(); + await operation('other-one', {}, 'other'); + await act(async () => view.rerender()); + await operation('three'); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + await act(async () => view.rerender()); + expect(screen.getByTestId('teacher-welcomes')).toBeVisible(); + await operation('other-two', {}, 'other'); + expect(screen.getByTestId('teacher-welcomes')).toBeVisible(); + await operation('other-three', {}, 'other'); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + await advance(1); + identity.id = 'new-kid'; + await act(async () => view.rerender()); + expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3); + identity.id = 'kid'; + await act(async () => view.rerender()); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + const added = { ...agents[0], teacher_id: 'new', definition: { ...agents[0].definition, config_id: 'new', name: '新老师' } }; + api.catalog.mockResolvedValue({ items: [...agents, added] }); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' }))); + expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(1); + await operation('new-one'); + await operation('new-two'); + expect(screen.getByRole('button', { name: '收起新老师的介绍' })).toBeVisible(); + await operation('new-three'); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + }); + + it('does not count teacher turns and still collapses the other teachers while consultation is open', async () => { + api.catalog.mockResolvedValue({ items: agents }); + const topic = { ...checkInTopic(), definition: agents[0].definition, requests: [], + conversation: { agentId: agents[0].teacher_id, segmentTurns: 0, discussions: {} } }; + api.sendConversation.mockResolvedValue(topic); + await mount(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '和方法老师聊聊' }))); + for (let i = 0; i < 3; i++) { + fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: `老师问题${i}` } }); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }))); + } + await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' }))); + expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(2); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' }))); + await operation('one'); await operation('two'); await operation('three'); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' }))); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + expect(api.sendConversation).toHaveBeenCalledTimes(3); + }); + 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 });