Merge grouped teacher introductions into latest main

This commit is contained in:
鲨鱼辣椒
2026-09-29 23:24:59 +08:00
13 changed files with 295 additions and 150 deletions

View File

@@ -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.

View File

@@ -20,19 +20,22 @@
## Outcome
- Not completed.
- Combined source 7458c37 with latest fetched remote 1c07f7b. All group visibility/dismissal, legacy compatibility and three-turn behavior retained; remote reasoning/body streaming untouched.
- Resolved the single Electron-spec conflict by retaining the stricter remote 8–9px native preview gap assertion. Retained new group lifecycle tests and remote streaming test. No product-code conflict.
## Verification
- Not run.
- Typecheck, production build and full lint passed (0 errors / 8 existing warnings). 501 related unit tests across 19 files, 22 browser layout/community cases and 5 Electron introduction/streaming/draft cases passed; logs /tmp/teacher-intro-merge-{typecheck,unit,lint,build,browser,electron}.log.
- Electron used a temporary generated-entry focus guard; original compiled entry restored. No live trial reload, model call, cloud action or focus change.
- Every source product/test file except combined README/Electron spec matches 7458c37 byte-for-byte, including the source task record. Canonical docs match remote 1c07f7b unchanged. Own-doc drift passed before merging; after merging, the checker flags the imported source task record as FOREIGN_DOC because it compares paths against the remote base. Verified that record is the exact immutable source blob, not a cross-task edit; preserved it rather than deleting history to silence the diagnostic.
## Follow-ups
- None recorded.
- Fast-forward validated candidate into the clean local main checkout; remote push is outside this request.
## Promotion Candidates
- None recorded.
- Canonical teacher ADR/domain/current-state should replace per-teacher introduction receipts with a unified account/project group, all-or-none dismissal and conservative legacy reads. User explicitly approved the change; README and source are authoritative for this merge. Canonical integration remains owned by the separate registered integration task.
## Project Context Loaded

View File

@@ -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 条快捷回复,没有时用空数组,不要求凑满。

View File

@@ -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,
<span className="teacher-welcome-text" title={item.text}>{item.text}</span>
<span className="teacher-welcome-action">和我聊聊 <ArrowUpRight aria-hidden="true" className="h-3 w-3" /></span>
</button>
<button type="button" className="teacher-welcome-dismiss" aria-label={`收起${item.definition.name}的介绍`} onClick={() => companion.dismissWelcome(item.agentId)}>
<button type="button" className="teacher-welcome-dismiss" aria-label={`收起全部老师介绍(${item.definition.name})`} onClick={() => companion.dismissWelcomes()}>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>

View File

@@ -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<unknown>(key, []) : [];
return Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string') : [];
}
function unreadInvitation(state: Pick<CompanionState, 'topic' | 'seen'>) {
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<CompanionState>({ agents: [], definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen: [], welcomeDismissed: false, error: '' });
const [unreadAgentIds, setUnreadAgentIds] = useState<string[]>([]);
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,

View File

@@ -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<IntroductionGroup, 'since' | 'runs'> {
if (!value || typeof value !== 'object') return false;
const progress = value as Partial<IntroductionGroup>;
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 };
}

View File

@@ -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<string, IntroductionProgress> {
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]);
}

View File

@@ -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);

View File

@@ -12,7 +12,7 @@ export function Layout() {
const [bubble, setBubble] = useState<HTMLDivElement | null>(null);
const [draft, setDraft] = useState('');
const [sent, setSent] = useState(false);
const [dismissed, setDismissed] = useState<string[]>([]);
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<typeof TeacherCompanion>['companion'];
return <div className="flex h-screen">
{new URLSearchParams(location.search).has('sidebar') && <aside data-testid="project-sidebar" className="shrink-0 border-r p-4 text-sm" style={{ width: 210, background: '#f1f6fc' }}>我的项目</aside>}

View File

@@ -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<string[]>([]);
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);

View File

@@ -2324,6 +2324,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);
@@ -2355,7 +2391,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); }
});

View File

@@ -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);
});

View File

@@ -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(<Harness projectId="other" />));
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(<Harness />));
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(<Harness />));
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(<Harness projectId="other" />));
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', () => {