From c82b1a43beeca2ab4f104a7566855c687a913aa5 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 14:02:50 +0800 Subject: [PATCH] feat(ui): introduce each teacher with an individual welcome bubble --- .../20260928-merge-reply-cleanup-eb8d39b3.md | 19 +++++ README.md | 2 +- src/pages/Chat/TeacherCompanion.tsx | 28 ++++++- src/pages/Chat/classroom-workspace.css | 22 +++++ src/pages/Chat/use-teacher-companion.ts | 40 ++++++++- tests/e2e/fixtures/teacher-preview-layout.tsx | 7 +- tests/e2e/pi-coding-first-chat.spec.ts | 30 +++---- tests/e2e/teacher-preview-layout.spec.ts | 46 ++++++++++- tests/unit/teacher-companion.test.tsx | 81 ++++++++++++++++++- 9 files changed, 253 insertions(+), 22 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md b/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md index 467fca80..9f5837bd 100644 --- a/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md +++ b/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md @@ -122,3 +122,22 @@ - Initial narrow-layout regression exposed an 11px Send shift with two-line text. Reserved action height resolves it without changing dispatch. All 71 focused UI tests, typecheck, changed-file ESLint and final production build pass. All 3 targeted headless layout cases pass at 319px/508px, including clipping, position, click payload and draft preservation; inspected narrow screenshot confirms balanced semantic wrapping and attached emoji. - Refreshed only the existing trial Renderer (Main remains PID 62668). Preflight found empty textareas, no stop controls and an idle Pi worker; saved current UI state/build backups under `help-long-label-update-20260929/`. Actual loaded `index-CaxXl4aZ.js` contains the full new copy. Current route and authenticated identity preserved, all 11 pre-refresh teacher drafts unchanged, window remains visible/unfocused. Consultation pane closes on page refresh; no extra teacher/operation model request was made to reopen it. - Temporary inspector closed. No formal-app change, model/cloud configuration publication or remote push. Required doc structure and whitespace checks pass; same inherited five foreign-source doc diagnostics remain from the original integration base. Retain checkout for running trial. + + +## Follow-up: Introduce Every Delivered Teacher + +- User approves the reviewed multi-teacher greeting design and requests it in the temporary application. Resume this owned UI/trial task with exact identity/branch/base verified, own/peer scopes reviewed, and unchanged entry/positioning/architecture context reused. Design skill guidance from the preceding review retained. Gate Passed. +- Replace the single selected-agent welcome presentation for delivered catalogs with individual cloud-configured introductions, pastel speech cards, short staggered entrance, individual dismissal and group “先自己试试”. Receipts belong to account + project + teacher; legacy shared dismissal must not incorrectly suppress other teachers. Existing hover introduction, account-wide chats, version/history/draft behavior and real unread check-ins remain intact. +- Plan: add bounded UI-only receipt state and accessible responsive cards, retain old no-catalog greeting/check-in path and measured native-preview avoidance; cover independent dismissal, new teachers, account/project boundaries, refresh/selection and no model requests; run relevant tests/typecheck/lint/build and headless layout, then safely refresh the existing trial without taking focus. No subagents, cloud publication, formal installation or remote push. + + +### Multi-Teacher Introduction Outcome And Verification + +- Delivered teachers now each have a separate pastel speech card (published description first, welcome message fallback, generic name-only greeting if neither exists), small avatar, explicit open action and individual close. Cards enter at 180ms offsets then remain, with bounded scrolling and reduced-motion support; “先自己试试” dismisses the current group. Opening one teacher consumes only their introduction. Hover still exposes the full description, and the introduction is not injected into chat history or model context. +- Local presentation receipts are keyed by account/project/teacher and survive refresh. Other projects/accounts and newly delivered teachers remain independent. Existing global welcome-dismissed data remains intact for no-catalog compatibility and does not suppress never-introduced catalog teachers. Preserve legacy proactive invitations and their unread acknowledgment, cloud definitions/version binding, normal selection, free input/drafts, billing and operation behavior; no backend/protocol change. +- Unit coverage verifies all-teacher display despite legacy dismissal, individual/remount/project/account boundaries, exact clicked-teacher routing, unpublished/new catalog entries after refresh, no reset of unread receipts and absence of model requests. Focused companion suite passes 39 tests; earlier combined relevant suites pass 119 tests before the four new regressions. Final full `pnpm test`: 263 suites / 2646 tests pass with 3 prior skips; separate pressure stage passes 1/1. +- Typecheck, full lint (0 errors, 8 existing warnings), production build and whitespace checks pass. All 13 headless preview/layout cases pass, including 375/768/1280px multi-card layouts, many-teacher scrolling, reduced motion, individual/all dismissal, exact selection, and measured native preview clearance. Desktop and narrow screenshots inspected. Updated native Electron scenarios parse/list 19 tests; foreground native E2E was not launched. +- Updated only the running trial Renderer to `index-D9hEfyAc.js`, retaining Main PID 62668, bundle/profile/port and prior lazy-load Main assets. Trial snapshot/backups are under `multi-teacher-welcome-20260929/`. Before refresh, verified no active request/worker and that the one nonempty teacher input exactly matched its account/agent persisted draft; backed it up instead of clearing it. All 13 persisted draft values, route and authenticated identity remain unchanged after refresh. +- Actual live DOM now has 2 delivered teacher tabs and 2 matching introduction cards (architecture teacher and Alice); screenshot inspected and cropped without activating the window. Window remains visible/unfocused. Focus monitor observed Codex only, and temporary inspector was closed. No cloud publication, live model question, formal install/restart or remote push; source change is committed locally. +- Required documentation structure passes; inherited drift check still reports only the same five imported task records relative to the original merge base. No foreign task record or canonical document edited. Retain this checkout because the trial runs its build. +- Promotion candidate: canonical classroom interaction/current-state should replace catalog-wide single welcome with project-scoped per-teacher introductory receipts and independent group presentation. Evidence: user's approved design, source/tests and live two-teacher capture. This supersedes the older shared welcome-dismissal UX for delivered catalogs only; legacy/check-in semantics remain compatible. No additional product decision is needed; canonical promotion requires a separate integration gate. diff --git a/README.md b/README.md index 8fbbe35e..7cbe4afd 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 鼠标悬停或键盘聚焦顶部智能体入口时,以奶油色圆角卡片显示该智能体的发布介绍;离开、Escape 或点击入口后收起,没有介绍则不显示空卡片。咨询栏不再显示重复的头像、名称标题条,也不显示介绍及新话题、展开、关闭三个图标;老师身份保留在顶部入口及消息署名中。智能体生成的追加问题以淡蓝、奶油黄、淡紫卡片依次承载,正文 16px、行高 1.8,保留整卡点击发送与忙碌时禁用。回复下方不再显示重复的输入引导或省略早期消息条数;输入框提示为“和老师聊聊”。再次点击当前入口或在咨询栏按 Escape 可收起,切换其他入口仍直接打开对应讨论;拖动边缘调宽保留,已下发智能体仅保留只读旧记录入口。悬浮介绍不会创建话题、调用模型或确认未读消息,作品原生预览同时避让介绍卡和真实主动气泡。 - 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 - 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 -- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;点击智能体进入咨询时立即收起欢迎语,并按账号记住已查看,关闭、重新打开、切换项目或刷新后不重复弹出;主动收起也保留同样记录。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 +- 进入项目时,每位已下发智能体各显示一个浅色自我介绍气泡,优先使用云端介绍,缺省使用发布欢迎语;不调用模型、不声称已检查项目。气泡依次出现后保留,可分别关闭或用“先自己试试”整组收起;按账号、项目、智能体记录已介绍状态,当前项目刷新后不重复,新增智能体单独补上。点击气泡或头像进入对应咨询,只记住这一位已介绍;咨询期间收起介绍组,关闭后其余未查看介绍仍可见,悬停头像仍可查看完整介绍。没有下发目录的旧入口保留原欢迎语展示兼容。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 - 智能体支持自由提问;输入框为空时,底部显示暖黄色小按钮“不知道问什么,你先帮我看看👀”,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,程序说明学生主动求助且尚未提出具体问题,智能体结合可用上下文按云端配置回应。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接;失败或中断后,可点暖黄色小按钮“不知道问什么,你先帮我看看👀”重试。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 - 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。 - 客户端只提供入口事实、可用上下文和界面协议。讨论切入点、教学方式、是否提问、正文长度、前缀和表达风格由所选智能体的云端配置决定;不在“不知道问什么,你先帮我看看👀”、引导开口或后台检查时附加教学策略。当前新回复允许 0–3 条快捷回复,没有时用空数组,不要求凑满。 diff --git a/src/pages/Chat/TeacherCompanion.tsx b/src/pages/Chat/TeacherCompanion.tsx index 56b9c39a..b9d53171 100644 --- a/src/pages/Chat/TeacherCompanion.tsx +++ b/src/pages/Chat/TeacherCompanion.tsx @@ -1,5 +1,5 @@ import { ArrowUpRight, Loader2, RefreshCw, X } from 'lucide-react'; -import { useState, type Ref } from 'react'; +import { useState, type CSSProperties, type Ref } from 'react'; import { Provider as TooltipProvider } from '@radix-ui/react-tooltip'; import { TeacherAvatar } from './TeacherAvatar'; import { TeacherIntroduction } from './TeacherIntroduction'; @@ -14,7 +14,7 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef, bubbleRef?: Ref; introductionRef?: Ref; }) { - const { definition, invitation, welcome, loading, enabled, checking, error } = companion; + const { definition, invitation, welcome, welcomes, loading, enabled, checking, error } = companion; const [introductionId, setIntroductionId] = useState(null); const introductionProps = (id: string) => ({ open: introductionId === id, @@ -25,6 +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 status = loading ? '正在连接智能体…' : checking ? '智能体在看看你最近的进展…' : error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说'); @@ -60,6 +61,29 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef, {companion.refreshing ? '刷新中' : companion.refreshStatus === 'error' ? '刷新失败' : companion.refreshStatus === 'success' ? '已更新' : '刷新'} {refreshMessage} + {!invitation && welcomes.length > 0 &&
+
来认识一下 + +
+
+ {welcomes.map((item, index) =>
+ +
+ + +
+
)} +
+
} {(invitation || welcome) &&
diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index 057c45c3..267c32d6 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -113,5 +113,27 @@ .teacher-companion-actions button:hover { text-decoration: underline; text-underline-offset: 4px; } .teacher-companion-speaker { flex-shrink: 0; width: 48px; height: 48px; margin-top: 5px; border-radius: 8px; } .teacher-companion-speaker img { width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; filter: drop-shadow(0 3px 1px #33485820); } +.teacher-welcome-presence { position: absolute; top: calc(100% + 12px); right: 0; z-index: 90; width: 340px; max-width: calc(100vw - 40px); padding: 6px; border-radius: 18px; background: #fffdf8; box-shadow: 0 8px 24px #30465f12; } +.teacher-welcome-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 8px 4px; color: #6a7463; font-size: 12px; } +.teacher-welcome-heading button { min-height: 32px; padding: 0 4px; color: #4d663c; border-radius: 8px; cursor: pointer; } +.teacher-welcome-heading button:hover { background: #eef1e8; } +.teacher-welcome-list { display: grid; gap: 10px; max-height: min(440px, calc(100vh - 200px)); overflow-y: auto; overscroll-behavior: contain; padding: 4px 6px 8px; scrollbar-width: thin; } +.teacher-welcome-card { --welcome-fill: #fff3ce; --welcome-edge: #efddb0; display: flex; align-items: flex-start; gap: 10px; min-width: 0; animation: teacher-welcome-arrive 220ms ease-out backwards; animation-delay: var(--welcome-delay, 0ms); } +.teacher-welcome-card[data-tone="1"] { --welcome-fill: #edf5ff; --welcome-edge: #d7e5f5; } +.teacher-welcome-card[data-tone="2"] { --welcome-fill: #f3edfc; --welcome-edge: #e5d9f5; } +.teacher-welcome-avatar { flex-shrink: 0; width: 42px; height: 44px; margin-top: 8px; padding: 2px; border: 1px solid var(--welcome-edge); border-radius: 14px; background: #fff; box-shadow: 0 3px 0 var(--welcome-edge); cursor: pointer; } +.teacher-welcome-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 11px; } +.teacher-welcome-bubble { position: relative; flex: 1; min-width: 0; border: 1px solid var(--welcome-edge); border-radius: 17px 17px 17px 6px; background: var(--welcome-fill); box-shadow: 0 2px 0 var(--welcome-edge); } +.teacher-welcome-bubble::before { content: ''; position: absolute; top: 23px; left: -5px; width: 8px; height: 8px; transform: rotate(45deg); border-left: 1px solid var(--welcome-edge); border-bottom: 1px solid var(--welcome-edge); background: var(--welcome-fill); } +.teacher-welcome-open { display: flex; flex-direction: column; gap: 6px; width: 100%; min-width: 0; padding: 12px; border-radius: inherit; color: #34465c; text-align: left; cursor: pointer; } +.teacher-welcome-open:hover { background: #ffffff40; } +.teacher-welcome-name { padding-right: 20px; font-size: 13px; font-weight: 600; line-height: 19px; overflow-wrap: anywhere; } +.teacher-welcome-text { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; white-space: pre-wrap; font-size: 13px; line-height: 1.6; } +.teacher-welcome-action { display: inline-flex; align-items: center; gap: 3px; color: #596b55; font-size: 11px; line-height: 18px; } +.teacher-welcome-dismiss { position: absolute; top: 5px; right: 5px; display: grid; place-items: center; width: 30px; height: 30px; color: #6e776b; border-radius: 10px; cursor: pointer; } +.teacher-welcome-dismiss:hover { background: #ffffff90; } +@keyframes teacher-welcome-arrive { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } +@media (prefers-reduced-motion: reduce) { .teacher-welcome-card { animation: none; } } +@media (max-width: 600px) { .teacher-welcome-presence { position: fixed; top: 64px; right: 12px; max-width: calc(100vw - 24px); } } /* Native preview clearance is measured against the bubble, not the whole pane. */ @media (max-height: 650px) { .teacher-companion-bubble { padding-top: 9px; } .teacher-companion-message { -webkit-line-clamp: 2; } } diff --git a/src/pages/Chat/use-teacher-companion.ts b/src/pages/Chat/use-teacher-companion.ts index 2f356390..d95c4386 100644 --- a/src/pages/Chat/use-teacher-companion.ts +++ b/src/pages/Chat/use-teacher-companion.ts @@ -42,6 +42,11 @@ 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' @@ -52,6 +57,21 @@ export function useTeacherCompanion(options: Options) { const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out'); const scope = account; 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); @@ -322,7 +342,20 @@ export function useTeacherCompanion(options: Options) { const selectedAgentId = visibleState.selectedAgent?.teacher_id ?? selectedDefinition?.config_id; const selectedAgent = visibleState.selectedAgent === null ? undefined : visibleState.agents.find((item) => item.teacher_id === selectedAgentId) ?? visibleState.selectedAgent; - const welcome = topic?.role !== 'friend' && !options.teacherOpen && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed + 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]); + const welcomes = options.projectId && !options.teacherOpen && !visibleState.loading + ? visibleState.agents.flatMap((agent, index) => introducedIds.includes(agent.teacher_id) ? [] : [{ + agentId: agent.teacher_id, + definition: agent.definition, + text: agent.definition.description.trim() || agent.definition.welcome_message.trim() + || `我是${agent.definition.name},一起聊聊你的想法吧。`, + tone: index % 3, + }]) : []; + const dismissWelcomes = () => dismissIntroductions(welcomes.map(item => item.agentId)); + const welcome = !visibleState.agents.length && topic?.role !== 'friend' && !options.teacherOpen && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed ? selectedDefinition?.welcome_message.trim() || null : null; return { unreadAgentIds, @@ -336,7 +369,10 @@ export function useTeacherCompanion(options: Options) { topic, invitation, welcome, - bubbleVisible: Boolean(invitation || welcome), + welcomes, + dismissWelcome, + dismissWelcomes, + bubbleVisible: Boolean(invitation || welcome || welcomes.length), checking: last?.intent === 'check-in' && ['preparing', 'running'].includes(last.status), dismiss, noteTopic, diff --git a/tests/e2e/fixtures/teacher-preview-layout.tsx b/tests/e2e/fixtures/teacher-preview-layout.tsx index 3d26e8f9..50ebed75 100644 --- a/tests/e2e/fixtures/teacher-preview-layout.tsx +++ b/tests/e2e/fixtures/teacher-preview-layout.tsx @@ -18,6 +18,8 @@ export function Layout() { const [refreshing, setRefreshing] = useState(false); 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 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', @@ -27,8 +29,11 @@ export function Layout() { const companion = { agents, unreadAgentIds: [], selectedAgent: agents[selected], definition: agents[selected]?.definition ?? { name: '编程老师', avatar_id: 'avatar-06' }, - invitation: visible ? { response: long ? '你刚才说想做一个宠物游戏。我们一起想想,什么样的小动作,会让你愿意一直陪它玩下去?可以先挑一个最想试的想法。' : '有什么编程问题想一起讨论?' } : null, + 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])]), + dismissWelcomes: () => setDismissed(agents.map(agent => agent.teacher_id)), 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 c02816c1..2d88f823 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -2186,9 +2186,9 @@ test('teacher avatar consumes the welcome bubble through close, reopen and reloa await page.getByTestId('ai-module-option-programming').click(); await page.evaluate(() => { window.location.hash = '/chat'; }); const teacherButton = page.getByRole('button', { name: '与代码智能体聊天', exact: true }); - const invitation = page.getByTestId('teacher-invitation'); + const invitation = page.locator('[data-testid="teacher-welcomes"] .teacher-welcome-card[data-agent-id="teacher-code"]'); const teacher = page.getByTestId('teacher-chat-panel'); - await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome'); + await expect(invitation).toBeVisible(); await teacherButton.click(); await expect(teacher).toBeVisible(); await expect(teacher.getByText('一起理解代码', { exact: true })).toBeVisible(); @@ -2205,6 +2205,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(); 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); } }); @@ -2229,9 +2230,9 @@ test('teacher stays in the top-right header without legacy check-ins or taking o await expect(teacherButton.locator('img')).toHaveAttribute('src', /avatar-01/); await expect(page.getByRole('checkbox', { name: '智能体偶尔来看看' })).toHaveCount(0); await expect(page.getByTestId('teacher-companion')).toHaveCount(1); - const invitation = companion.getByTestId('teacher-invitation'); - await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome'); - await expect(invitation).toContainText('一起理解代码'); + const invitation = companion.locator('.teacher-welcome-card[data-agent-id="teacher-code"]'); + await expect(invitation).toBeVisible(); + await expect(invitation).toContainText('一起发现代码里的小奥秘,把想法变成作品。'); await expect(invitation).toBeInViewport({ ratio: 1 }); expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0); await page.screenshot({ path: test.info().outputPath('teacher-entry-greeting.png') }); @@ -2261,27 +2262,28 @@ test('teacher stays in the top-right header without legacy check-ins or taking o expect(introductionViewportBounds!.y).toBeGreaterThanOrEqual(introductionBounds!.y + introductionBounds!.height); await page.keyboard.press('Escape'); await expect(introduction).toHaveCount(0); - await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome'); + await expect(invitation).toBeVisible(); expect((await readState(electronApp)).captured.filter(item => /\/(messages|teacher-check-in)$/.test(item.path) && item.method === 'POST')).toHaveLength(0); await page.getByRole('tab', { name: '操作对话', exact: true }).click(); await composer.focus(); await page.clock.fastForward(300_000); - await expect(invitation).toContainText('一起理解代码'); - await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome'); + await expect(invitation).toContainText('一起发现代码里的小奥秘,把想法变成作品。'); + await expect(invitation).toBeVisible(); expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0); await expect(invitation).toBeInViewport({ ratio: 1 }); - const presence = companion.getByTestId('teacher-presence'); - await expect(presence.getByRole('button', { name: '打开这条智能体消息' })).toBeVisible(); - await expect(presence.getByRole('button', { name: '打开这条智能体消息' }).locator('img')).toHaveAttribute('src', await teacherButton.locator('img').getAttribute('src') as string); + const presence = companion.getByTestId('teacher-welcomes'); + await expect(presence.getByRole('button', { name: '认识代码智能体' })).toBeVisible(); + await expect(presence.getByRole('button', { name: '认识代码智能体' }).locator('img')).toHaveAttribute('src', await teacherButton.locator('img').getAttribute('src') as string); const [invitationBounds, companionBounds, avatarBounds, presenceBounds, speakerBounds] = await Promise.all([ - invitation.boundingBox(), companion.boundingBox(), teacherButton.locator('img').boundingBox(), presence.boundingBox(), presence.getByRole('button', { name: '打开这条智能体消息' }).boundingBox(), + invitation.boundingBox(), companion.boundingBox(), teacherButton.locator('img').boundingBox(), presence.boundingBox(), presence.getByRole('button', { name: '认识代码智能体' }).boundingBox(), ]); expect(invitationBounds).not.toBeNull(); expect(companionBounds).not.toBeNull(); expect(avatarBounds).not.toBeNull(); expect(invitationBounds!.y).toBeGreaterThanOrEqual(avatarBounds!.y + avatarBounds!.height); expect(Math.abs(presenceBounds!.x + presenceBounds!.width - companionBounds!.x - companionBounds!.width)).toBeLessThanOrEqual(2); - expect(speakerBounds!.x - invitationBounds!.x - invitationBounds!.width).toBeLessThanOrEqual(10); + expect(speakerBounds!.x).toBeGreaterThanOrEqual(presenceBounds!.x); + expect(speakerBounds!.x + speakerBounds!.width).toBeLessThan(invitationBounds!.x + invitationBounds!.width); await expect(composer).toBeFocused(); await expect(composer).toHaveValue('我还在写自己的想法'); await expect(page.getByRole('tab', { name: '操作对话', exact: true })).toHaveAttribute('aria-selected', 'true'); @@ -2303,7 +2305,7 @@ test('teacher stays in the top-right header without legacy check-ins or taking o await expect(composer).toHaveValue('我还在写自己的想法'); await page.clock.fastForward(300_000); expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0); - await invitation.getByRole('button', { name: '和智能体聊聊' }).click(); + await invitation.getByRole('button', { name: '和代码智能体聊聊' }).click(); await expect(invitation).toHaveCount(0); await expect(presence).toHaveCount(0); await expect(teacherButton).toBeVisible(); diff --git a/tests/e2e/teacher-preview-layout.spec.ts b/tests/e2e/teacher-preview-layout.spec.ts index 1cc8be0b..a7850600 100644 --- a/tests/e2e/teacher-preview-layout.spec.ts +++ b/tests/e2e/teacher-preview-layout.spec.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path'; let server: ViteDevServer; let baseUrl: string; -test.use({ channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' }); +test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' }); test.beforeAll(async () => { server = await createServer({ configFile: false, @@ -35,6 +35,50 @@ test.beforeAll(async () => { }); test.afterAll(async () => { await server?.close(); }); +for (const width of [375, 768, 1280]) { + test(`all teacher introductions fit at ${width}px and keep the native preview clear`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto(baseUrl + '?agents=3&welcomes'); + const group = page.getByTestId('teacher-welcomes'); + await expect(group.locator('.teacher-welcome-card')).toHaveCount(3); + await expect(group).toBeInViewport({ ratio: 1 }); + for (const card of await group.locator('.teacher-welcome-card').all()) { + await expect(card).toBeInViewport({ ratio: 1 }); + expect(await card.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); + } + await expect.poll(async () => { + const [bubble, viewport] = await Promise.all([group.boundingBox(), page.getByTestId('agent-browser-viewport').boundingBox()]); + 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: '和智能体3聊聊' }).click(); + 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); + await group.getByRole('button', { name: '先自己试试' }).click(); + await expect(group).toHaveCount(0); + }); +} + +test('many teacher introductions remain scrollable and honor reduced motion', async ({ page }) => { + await page.setViewportSize({ width: 520, height: 600 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(baseUrl + '?agents=8&welcomes'); + const group = page.getByTestId('teacher-welcomes'); + await expect(group).toBeInViewport({ ratio: 1 }); + await expect(group.locator('.teacher-welcome-card')).toHaveCount(8); + await expect(group.locator('.teacher-welcome-card').first()).toHaveCSS('animation-name', 'none'); + const last = group.getByRole('button', { name: '和智能体8聊聊' }); + await last.scrollIntoViewIfNeeded(); + await expect(last).toBeInViewport({ ratio: 1 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(520); + await group.getByRole('button', { name: '先自己试试' }).click(); + await expect(group).toHaveCount(0); +}); + for (const width of [520, 1000]) { test(`distributed agent tabs scroll inside the title bar at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 800 }); diff --git a/tests/unit/teacher-companion.test.tsx b/tests/unit/teacher-companion.test.tsx index 31ec78b8..d070da70 100644 --- a/tests/unit/teacher-companion.test.tsx +++ b/tests/unit/teacher-companion.test.tsx @@ -7,8 +7,9 @@ import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel'; import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherCatalog, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher'; const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn(), conversation: vi.fn(), sendConversation: vi.fn(), seenConversation: vi.fn() })); +const identity = vi.hoisted(() => ({ id: 'kid' })); vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project })); -vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid' } }) })); +vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: identity.id } }) })); const definition: TeacherDefinition = { schema_version: 1, teacher_id: 'coding-teacher', name: '运营配置的智能体', description: '', avatar_id: 'avatar-06', welcome_message: '', suggested_questions: [], system_prompt: '指导', skills: [], model: { model_id: 'model', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 }, @@ -37,6 +38,7 @@ async function advance(ms = TEACHER_CHECK_IN_INTERVAL_MS) { } beforeEach(() => { vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear(); + identity.id = 'kid'; vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} @@ -52,6 +54,83 @@ beforeEach(() => { }); afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); }); +describe('individual 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}的聊天欢迎语` }, + })); + + it('introduces every delivered teacher independently of the old shared welcome dismissal without sending requests', async () => { + localStorage.setItem('makelore-teacher-presence:kid:welcome-dismissed', 'true'); + api.catalog.mockResolvedValue({ items: agents }); + await mount(); + const group = screen.getByTestId('teacher-welcomes'); + 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(); + expect(within(group).getByText('方法老师的自我介绍')).toBeVisible(); + expect(within(group).getByText('故事老师的自我介绍')).toBeVisible(); + await advance(); + expect(api.create).not.toHaveBeenCalled(); + expect(api.send).not.toHaveBeenCalled(); + expect(api.sendConversation).not.toHaveBeenCalled(); + expect(api.checkIn).not.toHaveBeenCalled(); + expect(api.seenConversation).not.toHaveBeenCalled(); + }); + + 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: '收起方法老师的介绍' }))); + first.unmount(); + const next = await mount(); + expect(screen.queryByRole('button', { name: '收起方法老师的介绍' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '收起体验老师的介绍' })).toBeVisible(); + await act(async () => next.rerender()); + 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(); + identity.id = 'another-kid'; + await act(async () => next.rerender()); + expect(screen.getByRole('button', { name: '收起方法老师的介绍' })).toBeVisible(); + }); + + it('opens the clicked teacher and consumes only their introduction, preserving the others for later', async () => { + api.catalog.mockResolvedValue({ items: agents }); + await mount(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '和故事老师聊聊' }))); + expect(screen.getByRole('button', { name: '与故事老师聊天' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('teacher-chat-panel')).toHaveTextContent('故事老师的聊天欢迎语'); + 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(api.create).not.toHaveBeenCalled(); + expect(api.sendConversation).not.toHaveBeenCalled(); + }); + + it('introduces only newly delivered teachers after dismiss-all, without resetting seen replies or existing receipts on refresh', async () => { + localStorage.setItem('makelore-teacher-presence:kid:seen', JSON.stringify(['keep-seen'])); + api.catalog.mockResolvedValue({ items: agents }); + await mount(); + 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(JSON.parse(localStorage.getItem('makelore-teacher-presence:kid:seen')!)).toEqual(['keep-seen']); + expect(api.sendConversation).not.toHaveBeenCalled(); + expect(api.checkIn).not.toHaveBeenCalled(); + }); +}); + describe('manual agent refresh', () => { it('refreshes while busy and composing, shares one request, and sends through the same agent after publication updates', async () => { const original = { teacher_id: 'refresh-first-method', version: 3, is_default: true, definition: { ...definition, config_id: 'refresh-first-method', name: '方法老师', welcome_message: '一起想想办法' } };