From 7458c37fb0a5f4705167740e6db6501feb26175a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 29 Sep 2026 23:06:27 +0800 Subject: [PATCH] fix: show and dismiss teacher introductions as one group --- .../20260929-teacher-group-intro-58c4d9a1.md | 57 +++++++++++ README.md | 2 +- src/pages/Chat/TeacherCompanion.tsx | 4 +- src/pages/Chat/use-teacher-companion.ts | 42 +++----- .../Chat/use-teacher-introduction-group.ts | 99 +++++++++++++++++++ .../Chat/use-teacher-introduction-rounds.ts | 67 ------------- tests/e2e/community-welcome.spec.ts | 4 +- tests/e2e/fixtures/community-welcome.tsx | 7 +- tests/e2e/fixtures/teacher-preview-layout.tsx | 6 +- tests/e2e/pi-coding-first-chat.spec.ts | 42 +++++++- tests/e2e/teacher-preview-layout.spec.ts | 10 +- tests/unit/teacher-companion.test.tsx | 98 ++++++++++++------ 12 files changed, 290 insertions(+), 148 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260929-teacher-group-intro-58c4d9a1.md create mode 100644 src/pages/Chat/use-teacher-introduction-group.ts delete mode 100644 src/pages/Chat/use-teacher-introduction-rounds.ts diff --git a/.project-docs/30-worklog/tasks/20260929-teacher-group-intro-58c4d9a1.md b/.project-docs/30-worklog/tasks/20260929-teacher-group-intro-58c4d9a1.md new file mode 100644 index 00000000..6240d37d --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260929-teacher-group-intro-58c4d9a1.md @@ -0,0 +1,57 @@ +# Task: Show and dismiss teacher introductions as one group + +## Identity + +- Task ID: 20260929-teacher-group-intro-58c4d9a1 +- Mode: Feature +- Branch: codex/20260929-teacher-group-intro-58c4d9a1-teacher-group-intro +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-teacher-group-intro-58c4d9a1 +- Base commit: a77ddbd6813bb71d490544edfef6e1f35240070f +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Treat all delivered teacher introduction bubbles as one account/project group: all visible or all dismissed. Opening any teacher, any close button, or three completed operation turns dismisses the group persistently. + +## Intent And Constraints + +- User explicitly approved grouped behavior, not re-showing on every entry. Preserve avatars, individual chat selection, hover descriptions, real unread messages, history/drafts, cloud prompts and billing. No subagents, paid model calls, automatic main merge/push or cloud publication. Background trial update retains its profile and desktop focus. + +## Outcome + +- Replaced individual teacher receipts/round windows with one durable account/project introduction group. All delivered teachers join the visible group; any close/open or three completed operation turns dismisses all. Refresh, added teachers and reentry never reopen a dismissed group. +- Preserved hover descriptions, selection, real unread replies, project/account isolation and Main-owned conversations. Both old local key namespaces migrate conservatively: any old dismissed teacher hides the whole group; otherwise deduplicated completed rounds continue. Legacy keys and history remain intact. +- Updated the running trial renderer in place (PID 32583) from its existing combined source plus this scoped patch. Main process/profile and empty-work overlay retained; no cloud changes, paid prompts, main merge or push. + +## Verification + +- Typecheck and production build passed in the owned worktree and composite trial stage. Lint: 0 errors, 8 pre-existing warnings. +- Focused unit tests: 86 passed (teacher companion, catalog refresh, coding chat panel), including both legacy namespaces, catalog growth, three successful turns, account/project switching and hover/unread preservation. +- Browser layout/community regressions: 22 passed at desktop and narrow widths; all teachers present, whole-group dismissal, transparent bubbles and native preview clearance. +- Electron regressions: 4 passed across initial run and focused retry. Updated one stale single-bubble geometry assertion to verify the entire group clears the native viewport with at most 9px spare; operation/teacher drafts and keyboard interactions pass. Test-only generated entry prevented native focus; original build entry restored. +- Live trial: index-ftMiOLQw.js loaded without activating its window; same route, 14 operation messages, teacher conversation content and empty inputs, original Alice panel restored. Existing presentation keys unchanged, new group migrated to dismissed. Auth refreshed access/expiry/activity/base metadata, same user retained. Temporary snapshot lease released (0 remaining), inspector closed, background throttling restored. Evidence: /tmp/teacher-group-{unit,browser,electron,electron-retry,typecheck,lint,build}.log and trial teacher-group-intro-58c4d9a1/{verification,auth-verification}.json. + +## Follow-ups + +- Await user authorization for integration/push; this feature branch remains separate. + +## Promotion Candidates + +- None recorded. + +## Project Context Loaded + +- Identity/ownership: feature task 20260929-teacher-group-intro-58c4d9a1 at a77ddbd in the recorded branch/worktree; official start/status passed. App worktree tool is unavailable for the non-Git parent; used official skill isolation. +- Read entry/memory/planning gates, active record, AGENTS and current intro source/tests. Reused previously loaded positioning, integrated state, decision/teacher ADR, system/domain/success, evidence/reflection/commitment documents after verifying their Git blobs unchanged from e30d357. Read all peer scopes/constraints/promotions; existing peers unchanged except new code-back-entry with undefined scope. No concrete conflict with the narrowly scoped teacher hooks; keep all peers untouched. +- Goal/boundaries: cloud-defined identities and descriptions in a Main-owned project creation client; user now supersedes the earlier independent teacher introduction receipts with all-or-none group visibility. Current main/README include latest merged client behavior; canonical docs remain read-only. +- Diagnostic evidence: the screenshot omitted Alice because a per-teacher introduced ID was persisted; current code also gives newly delivered teachers independent round windows. Both must become group state. Old local receipts are presentation data, not conversation history. +- Compatibility decision: any old per-teacher dismissal means the whole old group is dismissed; otherwise preserve shared operation-round progress. Keep old keys read-only. Newly delivered teachers join a still-visible group but never reopen a dismissed group. Preserve both historical presence key formats used by the trial and integrated main. +- Files: use-teacher-companion, introduction group hook, TeacherCompanion, related unit/browser/Electron fixtures, README. Trial Main can remain running; update a renderer build based on its current source to avoid changing unrelated runtime behavior. +- Gate: Passed. + +## Plan + +1. Replace per-teacher receipts/counters with one persistent account/project group and conservative legacy reads. +2. Adapt close/open and three-round regressions; cover catalog growth, reload/account/project isolation and unchanged hover/unread behavior. Run project checks. +3. Update the existing temporary app in the background, preserving live data and source overlays; verify and commit the feature with its task record. diff --git a/README.md b/README.md index 1f84dca6..cc876331 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 鼠标悬停或键盘聚焦顶部智能体入口时,以奶油色圆角卡片显示该智能体的发布介绍;离开、Escape 或点击入口后收起,没有介绍则不显示空卡片。咨询栏不再显示重复的头像、名称标题条,也不显示介绍及新话题、展开、关闭三个图标;老师身份保留在顶部入口及消息署名中。智能体生成的追加问题以淡蓝、奶油黄、淡紫卡片依次承载,正文 14px、行高 1.7,使用更紧凑的内边距和圆角,保留整卡点击发送与忙碌时禁用。回复下方不再显示重复的输入引导或省略早期消息条数;输入框提示为“和老师聊聊”。再次点击当前入口或在咨询栏按 Escape 可收起,切换其他入口仍直接打开对应讨论;拖动边缘调宽保留,已下发智能体仅保留只读旧记录入口。悬浮介绍不会创建话题、调用模型或确认未读消息,作品原生预览同时避让介绍卡和真实主动气泡。 - 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 - 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 -- 进入项目时,每位已下发智能体各显示一个浅色自我介绍气泡,外层透明,不显示公共底板、标题或整组关闭入口;整张气泡可点击,独立关闭按钮只收起该老师介绍,优先使用云端介绍,缺省使用发布欢迎语;不调用模型、不声称已检查项目。气泡进入项目即依次出现,不点击时保留到操作 Agent 第 3 轮完整回复结束后自动收起,也可分别关闭;按账号、项目、智能体保存计数及已介绍状态,刷新与操作对话切换延续进度。旧历史、老师咨询、工具调用、重试中间状态、失败/中止和其他项目不计入,新增智能体单独补上并从零计数。点击气泡或头像进入对应咨询,只记住这一位已介绍;咨询期间收起介绍组,关闭后其余未查看介绍仍可见,悬停头像仍可查看完整介绍。没有下发目录的旧入口保留原欢迎语展示兼容。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 +- 进入项目时,每位已下发智能体各显示一个浅色自我介绍气泡,外层透明,不显示公共底板、标题或整组关闭入口;整张气泡可点击,每张气泡保留关闭按钮,关闭任意一张会收起整组介绍,优先使用云端介绍,缺省使用发布欢迎语;不调用模型、不声称已检查项目。所有气泡进入项目即作为一组显示,不点击时保留到操作 Agent 第 3 轮完整回复结束后自动收起,也可整组关闭;按账号、项目保存统一计数及整组已介绍状态,刷新与操作对话切换延续进度。旧历史、老师咨询、工具调用、重试中间状态、失败/中止和其他项目不计入,新增智能体加入仍在展示的整组,不会单独重开已收起的介绍。点击气泡或头像进入对应咨询,同时记住整组已介绍;关闭咨询、刷新或重新进入项目后整组仍收起,悬停头像仍可查看完整介绍。旧版任一老师已介绍时兼容为整组已介绍,否则延续已有操作轮次;旧记录仅兼容读取,不清空历史。没有下发目录的旧入口保留原欢迎语展示兼容。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 - 智能体支持自由提问;输入框为空时,上方显示老师已发布的快捷提示,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,程序说明学生主动求助且尚未提出具体问题,智能体结合可用上下文按云端配置回应。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接;失败或中断后,可点暖黄色小按钮“帮我整体看🧠”重试。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 - 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。 - 客户端只提供入口事实、可用上下文和界面协议。讨论切入点、教学方式、是否提问、正文长度、前缀和表达风格由所选智能体的云端配置决定;不在快捷求助、引导开口或后台检查时附加教学策略。当前新回复允许 0–3 条快捷回复,没有时用空数组,不要求凑满。 diff --git a/src/pages/Chat/TeacherCompanion.tsx b/src/pages/Chat/TeacherCompanion.tsx index bc6b7188..6b1a21e7 100644 --- a/src/pages/Chat/TeacherCompanion.tsx +++ b/src/pages/Chat/TeacherCompanion.tsx @@ -25,7 +25,7 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef, const agents = companion.selectedAgent && !companion.agents.some((item) => item.teacher_id === activeId) ? [...companion.agents, companion.selectedAgent] : companion.agents; const viewConversation = () => { companion.dismiss(); onOpen(); }; - const meetTeacher = (agentId: string) => { setIntroductionId(null); companion.dismissWelcome(agentId); onOpen(agentId); }; + const meetTeacher = (agentId: string) => { setIntroductionId(null); companion.dismissWelcomes(); onOpen(agentId); }; const status = loading ? '正在连接智能体…' : checking ? '智能体在看看你最近的进展…' : error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说'); @@ -74,7 +74,7 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef, {item.text} 和我聊聊 - diff --git a/src/pages/Chat/use-teacher-companion.ts b/src/pages/Chat/use-teacher-companion.ts index 0cb281f7..4ce6c713 100644 --- a/src/pages/Chat/use-teacher-companion.ts +++ b/src/pages/Chat/use-teacher-companion.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { teacherApi, teacherTopicsPath, teacherConversationsPath, 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 { useTeacherIntroductionGroup } from './use-teacher-introduction-group'; import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherCheckInInput, @@ -43,11 +43,6 @@ function writeLocal(key: string, value: unknown) { try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* In-memory state remains usable. */ } } -function readIntroducedAgents(key: string | null): string[] { - const value = key ? readLocal(key, []) : []; - return Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string') : []; -} - function unreadInvitation(state: Pick) { if (state.topic?.conversation) return null; return state.topic?.requests.slice().reverse().find((request) => request.intent === 'check-in' @@ -58,21 +53,6 @@ export function useTeacherCompanion(options: Options) { const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out'); const scope = account + ':' + (options.projectId ?? 'no-project'); const storageKey = `makelore-teacher-presence:${scope}`; - const introductionKey = options.projectId - ? `${storageKey}:project:${encodeURIComponent(options.projectId)}:introduced-agents` : null; - const [introductions, setIntroductions] = useState(() => ({ key: introductionKey, ids: readIntroducedAgents(introductionKey) })); - const introducedIds = introductions.key === introductionKey ? introductions.ids : readIntroducedAgents(introductionKey); - const dismissIntroductions = useCallback((ids: string[]) => { - if (!introductionKey || !ids.length) return; - setIntroductions(current => { - const previous = current.key === introductionKey ? current.ids : readIntroducedAgents(introductionKey); - const next = [...new Set([...previous, ...ids])]; - if (current.key === introductionKey && next.length === previous.length) return current; - writeLocal(introductionKey, next); - return { key: introductionKey, ids: next }; - }); - }, [introductionKey]); - const dismissWelcome = useCallback((agentId: string) => dismissIntroductions([agentId]), [dismissIntroductions]); const [state, setState] = useState({ agents: [], definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen: [], welcomeDismissed: false, error: '' }); const [unreadAgentIds, setUnreadAgentIds] = useState([]); const selectionEpoch = useRef(0); @@ -357,21 +337,21 @@ 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 introductions = useTeacherIntroductionGroup(account, options.projectId, + !visibleState.loading && visibleState.agents.length > 0); + const dismissWelcomes = introductions.dismiss; useEffect(() => { - // 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) ? [] : [{ + // Opening any teacher dismisses the whole introduction group, never unread replies. + if (options.teacherOpen && selectedAgent?.teacher_id) dismissWelcomes(); + }, [options.teacherOpen, selectedAgent?.teacher_id, dismissWelcomes]); + const welcomes = options.projectId && !options.teacherOpen && !visibleState.loading && !introductions.dismissed + ? visibleState.agents.map((agent, index) => ({ agentId: agent.teacher_id, definition: agent.definition, text: agent.definition.description.trim() || agent.definition.welcome_message.trim() || `我是${agent.definition.name},一起聊聊你的想法吧。`, tone: index % 3, - }]) : []; + })) : []; const welcome = !visibleState.agents.length && topic?.role !== 'friend' && !options.teacherOpen && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed ? selectedDefinition?.welcome_message.trim() || null : null; return { @@ -387,7 +367,7 @@ export function useTeacherCompanion(options: Options) { invitation, welcome, welcomes, - dismissWelcome, + dismissWelcomes, bubbleVisible: Boolean(invitation || welcome || welcomes.length), checking: last?.intent === 'check-in' && ['preparing', 'running'].includes(last.status), dismiss, diff --git a/src/pages/Chat/use-teacher-introduction-group.ts b/src/pages/Chat/use-teacher-introduction-group.ts new file mode 100644 index 00000000..4134bf70 --- /dev/null +++ b/src/pages/Chat/use-teacher-introduction-group.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { codingConversationStore, type CodingConversationStoreState } from '@/stores/coding-conversations'; + +interface IntroductionGroup { + version: 1; + dismissed: boolean; + since: number; + runs: string[]; +} + +function readJson(key: string): unknown { + try { return JSON.parse(localStorage.getItem(key) ?? 'null'); } catch { return null; } +} + +function isProgress(value: unknown): value is Pick { + if (!value || typeof value !== 'object') return false; + const progress = value as Partial; + return typeof progress.since === 'number' && Number.isFinite(progress.since) + && Array.isArray(progress.runs) && progress.runs.length <= 3 + && progress.runs.every(run => typeof run === 'string'); +} + +function readGroup(key: string, account: string, projectId: string): IntroductionGroup { + const saved = readJson(key); + if (isProgress(saved) && 'version' in saved && saved.version === 1 + && 'dismissed' in saved && typeof saved.dismissed === 'boolean') { + return { version: 1, dismissed: saved.dismissed || saved.runs.length >= 3, since: saved.since, runs: saved.runs }; + } + // Read both previous presence namespaces. Keep their receipts and all chat data untouched. + const legacyKeys = [...new Set([ + `makelore-teacher-presence:${account}:${projectId}:project:${encodeURIComponent(projectId)}:introduced-agents`, + `makelore-teacher-presence:${account}:project:${encodeURIComponent(projectId)}:introduced-agents`, + ])]; + const group: IntroductionGroup = { version: 1, dismissed: false, since: Date.now(), runs: [] }; + for (const legacyKey of legacyKeys) { + const ids = readJson(legacyKey); + if (Array.isArray(ids) && ids.some(id => typeof id === 'string' && id.length > 0)) group.dismissed = true; + const rounds = readJson(legacyKey + ':rounds'); + if (!rounds || typeof rounds !== 'object' || Array.isArray(rounds)) continue; + for (const progress of Object.values(rounds).filter(isProgress)) { + group.since = Math.min(group.since, progress.since); + group.runs = [...new Set([...group.runs, ...progress.runs])].slice(0, 3); + } + } + group.dismissed ||= group.runs.length >= 3; + return group; +} + +function persist(key: string, value: IntroductionGroup) { + try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* Keep the current window usable. */ } +} + +/** One presentation receipt per account/project; teacher catalog changes never split the group. */ +export function useTeacherIntroductionGroup(account: string, projectId: string | null, enabled: boolean) { + const key = projectId && enabled ? `makelore-teacher-presence:${account}:${projectId}:introduction-group` : null; + const initial = useMemo(() => key && projectId ? readGroup(key, account, projectId) : null, [key, account, projectId]); + const [state, setState] = useState(() => ({ key, value: initial })); + const value = state.key === key ? state.value : initial; + const latest = useRef({ key, value }); + useLayoutEffect(() => { latest.current = { key, value }; }, [key, value]); + const commit = useCallback((next: IntroductionGroup) => { + if (!key || latest.current.key !== key) return; + latest.current = { key, value: next }; + persist(key, next); + setState({ key, value: next }); + }, [key]); + const dismiss = useCallback(() => { + if (latest.current.key !== key || !latest.current.value || latest.current.value.dismissed) return; + commit({ ...latest.current.value, dismissed: true }); + }, [key, commit]); + + useEffect(() => { + if (!key || !projectId || !latest.current.value) return; + persist(key, latest.current.value); + const observe = (store: CodingConversationStoreState) => { + const current = latest.current.value; + if (latest.current.key !== key || !current || current.dismissed) return; + const runs = new Set(current.runs); + for (const entry of Object.values(store.entriesByConversationId)) { + const snapshot = entry.reducer.snapshot; + if (entry.loadState !== 'live' || entry.reducer.invalidation || snapshot?.conversation.projectId !== projectId) continue; + const run = snapshot.run; + // Count successful top-level turns only; ignore old history, tools and retry intermediates. + if (run.status !== 'idle' || run.terminalReason !== 'completed' || run.mode !== 'prompt' + || !run.runId || run.startedAt === undefined || run.startedAt < current.since) continue; + runs.add(JSON.stringify([snapshot.conversation.id, run.runId])); + } + if (runs.size !== current.runs.length) { + commit({ ...current, runs: [...runs].slice(0, 3), dismissed: runs.size >= 3 }); + } + }; + observe(codingConversationStore.getState()); + return codingConversationStore.subscribe((store, previous) => { + if (store.entriesByConversationId !== previous.entriesByConversationId) observe(store); + }); + }, [key, projectId, commit]); + + return { dismissed: value?.dismissed ?? false, dismiss }; +} diff --git a/src/pages/Chat/use-teacher-introduction-rounds.ts b/src/pages/Chat/use-teacher-introduction-rounds.ts deleted file mode 100644 index 161219c3..00000000 --- a/src/pages/Chat/use-teacher-introduction-rounds.ts +++ /dev/null @@ -1,67 +0,0 @@ -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/community-welcome.spec.ts b/tests/e2e/community-welcome.spec.ts index b2a99fd0..eff697c2 100644 --- a/tests/e2e/community-welcome.spec.ts +++ b/tests/e2e/community-welcome.spec.ts @@ -65,7 +65,7 @@ for (const width of [375, 768, 1280, 1470]) { await page.getByTestId('community-invitation').scrollIntoViewIfNeeded(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); await expect(page.getByRole('button', { name: '去作品广场逛逛' })).toBeVisible(); - while (await teachers.count()) await teachers.getByRole('button', { name: /^收起.*的介绍$/ }).first().click(); + while (await teachers.count()) await teachers.getByRole('button', { name: /^收起全部老师介绍/ }).first().click(); await heading.scrollIntoViewIfNeeded(); const composer = page.getByTestId('coding-message-composer'); await expect(composer).toBeInViewport({ ratio: 1 }); @@ -125,7 +125,7 @@ for (const width of [1280, 1470]) { }).toBeLessThan(2); const inputBeforeDismiss = await composer.boundingBox(); const headingBeforeDismiss = await heading.boundingBox(); - while (await teachers.count()) await teachers.getByRole('button', { name: /^收起.*的介绍$/ }).first().click(); + while (await teachers.count()) await teachers.getByRole('button', { name: /^收起全部老师介绍/ }).first().click(); await expect(composer.getByRole('textbox')).toHaveValue('保留这份草稿'); expect(await composer.boundingBox()).toEqual(inputBeforeDismiss); expect(await heading.boundingBox()).toEqual(headingBeforeDismiss); diff --git a/tests/e2e/fixtures/community-welcome.tsx b/tests/e2e/fixtures/community-welcome.tsx index baf21c41..33fa9d23 100644 --- a/tests/e2e/fixtures/community-welcome.tsx +++ b/tests/e2e/fixtures/community-welcome.tsx @@ -12,7 +12,7 @@ export function Layout() { const [bubble, setBubble] = useState(null); const [draft, setDraft] = useState(''); const [sent, setSent] = useState(false); - const [dismissed, setDismissed] = useState([]); + const [dismissed, setDismissed] = useState(false); const agents = ['架构工程师-陈宗琦', '产品工程师-Alice'].map((name, index) => ({ teacher_id: String(index), version: 1, is_default: index === 0, definition: { config_id: String(index), name, avatar_id: 'avatar-06', @@ -22,9 +22,8 @@ export function Layout() { agents, selectedAgent: agents[0], definition: agents[0].definition, enabled: true, unreadAgentIds: [], invitation: null, welcome: null, loading: false, error: null, checking: false, refreshing: false, refreshStatus: 'idle', refresh: async () => {}, reloadCatalog: async () => {}, dismiss: () => {}, - welcomes: new URLSearchParams(location.search).has('teachers') ? agents.flatMap((agent, index) => dismissed.includes(agent.teacher_id) - ? [] : [{ agentId: agent.teacher_id, definition: agent.definition, text: agent.definition.description, tone: index }]) : [], - dismissWelcome: (id: string) => setDismissed(values => [...values, id]), + welcomes: new URLSearchParams(location.search).has('teachers') && !dismissed ? agents.map((agent, index) => ({ agentId: agent.teacher_id, definition: agent.definition, text: agent.definition.description, tone: index })) : [], + dismissWelcomes: () => setDismissed(true), } as ComponentProps['companion']; return
{new URLSearchParams(location.search).has('sidebar') && } diff --git a/tests/e2e/fixtures/teacher-preview-layout.tsx b/tests/e2e/fixtures/teacher-preview-layout.tsx index 36edd026..e453b43b 100644 --- a/tests/e2e/fixtures/teacher-preview-layout.tsx +++ b/tests/e2e/fixtures/teacher-preview-layout.tsx @@ -19,7 +19,7 @@ export function Layout() { 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([]); + const [dismissed, setDismissed] = useState(false); 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', @@ -31,8 +31,8 @@ export function Layout() { definition: agents[selected]?.definition ?? { name: '编程老师', avatar_id: 'avatar-06' }, 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])]), + welcomes: welcomeMode && !open && !dismissed ? agents.map((agent, index) => ({ agentId: agent.teacher_id, definition: agent.definition, text: agent.definition.description, tone: index % 3 })) : [], + dismissWelcomes: () => setDismissed(true), refreshing, refreshStatus, refresh: async () => { setRefreshing(true); diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index 543c075a..9baef5e3 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -2322,6 +2322,42 @@ test('teacher introductions collapse after three operation replies and remain av } finally { await releaseSnapshot(electronApp); } }); +test('teacher introduction group closes all three and stays closed for a new teacher after refresh and reload', 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); + const addTeacher = async (id: string) => electronApp.evaluate((_electron, id) => { + const state = (globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture }).__makelorePiFirstChatE2E; + const original = state.teacherCatalog.items[0]; + state.teacherCatalog.items.push({ ...original, teacher_id: id, is_default: false, + definition: { ...original.definition, config_id: id, name: id } }); + }, id); + try { + await addTeacher('第三位老师'); + await page.reload(); page = await getStableWindow(electronApp); + await page.getByTestId('ai-module-option-programming').click(); + await page.evaluate(() => { window.location.hash = '/chat'; }); + const group = page.getByTestId('teacher-welcomes'); + await expect(group.locator('.teacher-welcome-card')).toHaveCount(3); + await group.getByRole('button', { name: '收起全部老师介绍(朋友)' }).click(); + await expect(group).toHaveCount(0); + await expect(page.getByTestId('teacher-chat-panel')).toHaveCount(0); + await addTeacher('第四位老师'); + await page.getByRole('button', { name: '刷新智能体', exact: true }).click(); + await expect(page.getByRole('button', { name: '与第四位老师聊天', exact: true })).toBeVisible(); + await expect(group).toHaveCount(0); + await page.reload(); + await expect(page.getByRole('button', { name: '与第四位老师聊天', exact: true })).toBeVisible(); + await expect(group).toHaveCount(0); + await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).hover(); + await expect(page.getByTestId('teacher-introduction')).toContainText('一起发现代码里的小奥秘'); + 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); @@ -2353,7 +2389,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(); + await expect(page.getByTestId('teacher-welcomes')).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); } }); @@ -2447,8 +2483,8 @@ test('teacher stays in the top-right header without legacy check-ins or taking o expect(browserViewportBounds!.y).toBeGreaterThanOrEqual(workInvitationBounds!.y + workInvitationBounds!.height); const workPane = page.getByRole('tabpanel', { name: '作品', exact: true }); const workPanel = page.getByTestId('agent-browser-panel'); - // Account for existing DOM toolbars instead of reserving a full bubble row. - expect((await workPanel.boundingBox())!.y - (await workPane.boundingBox())!.y).toBeLessThan(65); + // Clear the entire group with only a small gap above the native viewport. + expect(browserViewportBounds!.y - (workInvitationBounds!.y + workInvitationBounds!.height)).toBeLessThanOrEqual(9); await page.getByRole('tab', { name: '操作对话', exact: true }).click(); await expect(composer).toHaveValue('我还在写自己的想法'); await page.clock.fastForward(300_000); diff --git a/tests/e2e/teacher-preview-layout.spec.ts b/tests/e2e/teacher-preview-layout.spec.ts index ee016f61..2e33e951 100644 --- a/tests/e2e/teacher-preview-layout.spec.ts +++ b/tests/e2e/teacher-preview-layout.spec.ts @@ -79,15 +79,15 @@ for (const width of [375, 768, 1280]) { 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: '收起全部老师介绍(智能体2)' }).click(); + await expect(group).toHaveCount(0); await expect(page.locator('#coding-consultation-dock')).toHaveCount(0); + await page.reload(); + await expect(group.locator('.teacher-welcome-card')).toHaveCount(3); await group.getByRole('button', { name: '和智能体3聊聊' }).click({ position: { x: 8, y: 8 } }); 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); - while (await group.count()) await group.getByRole('button', { name: /^收起.*的介绍$/ }).first().click(); await expect(group).toHaveCount(0); }); } @@ -108,8 +108,6 @@ test('many teacher introductions remain scrollable and honor reduced motion', as await page.keyboard.press('Enter'); await expect(page.getByRole('button', { name: '与智能体8聊天' })).toHaveAttribute('aria-pressed', 'true'); await page.getByRole('button', { name: '关闭智能体栏' }).click(); - await expect(group.locator('.teacher-welcome-card')).toHaveCount(7); - while (await group.count()) await group.getByRole('button', { name: /^收起.*的介绍$/ }).first().click(); await expect(group).toHaveCount(0); }); diff --git a/tests/unit/teacher-companion.test.tsx b/tests/unit/teacher-companion.test.tsx index 672adec8..26e4dfd8 100644 --- a/tests/unit/teacher-companion.test.tsx +++ b/tests/unit/teacher-companion.test.tsx @@ -59,7 +59,7 @@ beforeEach(() => { }); afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); }); -describe('individual teacher introductions', () => { +describe('grouped teacher introductions', () => { const agents = ['方法老师', '体验老师', '故事老师'].map((name, index) => ({ teacher_id: `intro-${index}`, version: 1, is_default: index === 0, definition: { ...definition, config_id: `intro-${index}`, name, description: `${name}的自我介绍`, welcome_message: `${name}的聊天欢迎语` }, @@ -133,7 +133,7 @@ describe('individual teacher introductions', () => { expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); }); - it('keeps round windows independent for projects/accounts and newly delivered teachers', async () => { + it('keeps project/account windows independent without reopening for newly delivered teachers', async () => { api.catalog.mockResolvedValue({ items: agents }); const view = await mount(); await operation('one'); @@ -161,15 +161,10 @@ describe('individual teacher introductions', () => { 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 () => { + it('keeps the entire group dismissed after teacher turns and closing consultation', async () => { api.catalog.mockResolvedValue({ items: agents }); const topic = { ...checkInTopic(), definition: agents[0].definition, requests: [], conversation: { agentId: agents[0].teacher_id, segmentTurns: 0, discussions: {} } }; @@ -181,7 +176,7 @@ describe('individual teacher introductions', () => { 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); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); 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: '与方法老师聊天' }))); @@ -198,11 +193,9 @@ describe('individual teacher introductions', () => { expect(within(group).queryByRole('button', { name: '先自己试试' })).not.toBeInTheDocument(); for (const agent of agents) expect(within(group).getByText(agent.definition.description)).toBeVisible(); expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); - await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起体验老师的介绍' }))); - expect(within(group).queryByText('体验老师的自我介绍')).not.toBeInTheDocument(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起全部老师介绍(体验老师)' }))); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument(); - expect(within(group).getByText('方法老师的自我介绍')).toBeVisible(); - expect(within(group).getByText('故事老师的自我介绍')).toBeVisible(); await advance(); expect(api.create).not.toHaveBeenCalled(); expect(api.send).not.toHaveBeenCalled(); @@ -214,24 +207,24 @@ describe('individual teacher introductions', () => { it('keeps dismissal across reloads for this account/project while leaving other projects and accounts independent', async () => { api.catalog.mockResolvedValue({ items: agents }); const first = await mount(); - await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起方法老师的介绍' }))); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起全部老师介绍(方法老师)' }))); first.unmount(); const next = await mount(); - expect(screen.queryByRole('button', { name: '收起方法老师的介绍' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: '收起体验老师的介绍' })).toBeVisible(); + expect(screen.queryByRole('button', { name: '收起全部老师介绍(方法老师)' })).not.toBeInTheDocument(); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); await act(async () => next.rerender()); - expect(screen.getByRole('button', { name: '收起方法老师的介绍' })).toBeVisible(); - for (const agent of agents) await act(async () => fireEvent.click(screen.getByRole('button', { name: `收起${agent.definition.name}的介绍` }))); + expect(screen.getByRole('button', { name: '收起全部老师介绍(方法老师)' })).toBeVisible(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起全部老师介绍(方法老师)' }))); expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); await act(async () => next.rerender()); - expect(screen.queryByRole('button', { name: '收起方法老师的介绍' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: '收起体验老师的介绍' })).toBeVisible(); + expect(screen.queryByRole('button', { name: '收起全部老师介绍(方法老师)' })).not.toBeInTheDocument(); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); identity.id = 'another-kid'; await act(async () => next.rerender()); - expect(screen.getByRole('button', { name: '收起方法老师的介绍' })).toBeVisible(); + expect(screen.getByRole('button', { name: '收起全部老师介绍(方法老师)' })).toBeVisible(); }); - it('opens the clicked teacher and consumes only their introduction, preserving the others for later', async () => { + it('opens the clicked teacher and consumes the whole group', async () => { api.catalog.mockResolvedValue({ items: agents }); await mount(); await act(async () => fireEvent.click(within(screen.getByTestId('teacher-welcomes')).getByText('故事老师的自我介绍'))); @@ -240,27 +233,74 @@ describe('individual teacher introductions', () => { expect(screen.getByTestId('teacher-chat-panel')).not.toHaveTextContent('故事老师的自我介绍'); expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); await act(async () => fireEvent.click(screen.getByRole('button', { name: '与故事老师聊天' }))); - expect(screen.queryByRole('button', { name: '收起故事老师的介绍' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: '收起方法老师的介绍' })).toBeVisible(); - expect(screen.getByRole('button', { name: '收起体验老师的介绍' })).toBeVisible(); + expect(screen.queryByRole('button', { name: '收起全部老师介绍(故事老师)' })).not.toBeInTheDocument(); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); expect(api.create).not.toHaveBeenCalled(); expect(api.sendConversation).not.toHaveBeenCalled(); }); - it('introduces only newly delivered teachers after individually dismissing every introduction, without resetting seen replies or existing receipts on refresh', async () => { + it('keeps refreshed and newly delivered teachers dismissed without resetting seen replies', async () => { localStorage.setItem('makelore-teacher-presence:kid:seen', JSON.stringify(['keep-seen'])); api.catalog.mockResolvedValue({ items: agents }); await mount(); - for (const agent of agents) await act(async () => fireEvent.click(screen.getByRole('button', { name: `收起${agent.definition.name}的介绍` }))); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起全部老师介绍(方法老师)' }))); const added = { ...agents[0], teacher_id: 'new', definition: { ...agents[0].definition, config_id: 'new', name: '新老师', description: '', welcome_message: '新老师的云端欢迎语' } }; api.catalog.mockResolvedValue({ items: [...agents.map(agent => ({ ...agent, version: 2 })), added] }); await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' }))); - expect(screen.getByTestId('teacher-welcomes')).toHaveTextContent('新老师的云端欢迎语'); - for (const agent of agents) expect(screen.queryByRole('button', { name: `收起${agent.definition.name}的介绍` })).not.toBeInTheDocument(); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + for (const agent of agents) expect(screen.queryByRole('button', { name: `收起全部老师介绍(${agent.definition.name})` })).not.toBeInTheDocument(); expect(JSON.parse(localStorage.getItem('makelore-teacher-presence:kid:seen')!)).toEqual(['keep-seen']); expect(api.sendConversation).not.toHaveBeenCalled(); expect(api.checkIn).not.toHaveBeenCalled(); }); + it('adds every new teacher to the visible group without resetting the shared count', async () => { + api.catalog.mockResolvedValue({ items: agents }); + await mount(); + await operation('one'); await operation('two'); + 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(4); + await operation('three'); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + }); + + const legacyKeys = [ + 'makelore-teacher-presence:kid:project:project:introduced-agents', + 'makelore-teacher-presence:kid:project:project:project:introduced-agents', + ]; + it.each(legacyKeys)('migrates partial dismissal from %s to the entire group without changing old data', async (key) => { + const old = JSON.stringify(['intro-1']); + localStorage.setItem(key, old); + api.catalog.mockResolvedValue({ items: agents }); + const first = await mount(); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + fireEvent.pointerEnter(screen.getByRole('button', { name: '与方法老师聊天' }), { pointerType: 'mouse' }); + expect(screen.getByRole('tooltip')).toHaveTextContent('方法老师的自我介绍'); + first.unmount(); + const next = await mount(); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + expect(localStorage.getItem(key)).toBe(old); + await act(async () => next.rerender()); + expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3); + }); + + it.each(legacyKeys)('continues legacy operation counts from %s without discarding history', async (key) => { + const roundsKey = key + ':rounds'; + const rounds = JSON.stringify(Object.fromEntries(agents.map(agent => [agent.teacher_id, { + since: Date.now() - 1000, runs: [JSON.stringify(['source', 'one']), JSON.stringify(['source', 'two'])], + }]))); + localStorage.setItem(roundsKey, rounds); + api.catalog.mockResolvedValue({ items: agents }); + await mount(); + expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3); + await operation('two'); + expect(screen.getByTestId('teacher-welcomes')).toBeVisible(); + await operation('three'); + expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument(); + expect(localStorage.getItem(roundsKey)).toBe(rounds); + }); + }); describe('manual agent refresh', () => {