From a10cf000157a1a3edcfad43705e0824f19568a33 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 24 Sep 2026 15:38:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9C=A8=E9=A1=B6=E6=A0=8F=E5=B9=B6?= =?UTF-8?q?=E6=8E=92=E5=B1=95=E7=A4=BA=E4=B8=8B=E5=8F=91=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E5=B9=B6=E7=9B=B4=E6=8E=A5=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260924-consultation-topbar-22717e99.md | 46 +++++++++++ README.md | 2 +- electron/coding-teacher/store.ts | 1 + shared/coding-teacher.ts | 2 +- src/pages/Chat/CodingChatPanel.tsx | 10 ++- src/pages/Chat/TeacherChatPanel.tsx | 56 ++++++++------ src/pages/Chat/TeacherCompanion.tsx | 21 ++++- src/pages/Chat/classroom-workspace.css | 4 +- src/pages/Chat/use-teacher-companion.ts | 51 +++++++++--- tests/e2e/fixtures/teacher-preview-layout.tsx | 13 +++- tests/e2e/pi-coding-first-chat.spec.ts | 17 ++-- tests/e2e/teacher-preview-layout.spec.ts | 26 ++++++- tests/unit/coding-teacher-ui.test.tsx | 22 +++++- tests/unit/coding-teacher.test.ts | 2 + tests/unit/teacher-companion.test.tsx | 77 ++++++++++++++++++- 15 files changed, 291 insertions(+), 59 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260924-consultation-topbar-22717e99.md diff --git a/.project-docs/30-worklog/tasks/20260924-consultation-topbar-22717e99.md b/.project-docs/30-worklog/tasks/20260924-consultation-topbar-22717e99.md new file mode 100644 index 00000000..a51c1297 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260924-consultation-topbar-22717e99.md @@ -0,0 +1,46 @@ +# Task: Show distributed consultation agents in title bar + +## Identity + +- Task ID: 20260924-consultation-topbar-22717e99 +- Mode: Feature +- Branch: codex/20260924-consultation-topbar-22717e99-consultation-topbar +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260924-consultation-topbar-22717e99 +- Base commit: 39b03a1628bcdbc9c6bcf82b874e2814bf1780d0 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Show every server-distributed consultation agent in the Code title bar with avatar/name and direct selection; remove the panel's new-topic agent dropdown. Preserve pinned topic versions, isolate drafts/history by agent and fit narrow windows. + +## Intent And Constraints + +- Concurrent/Planning Gates Passed: official check/start/status match isolated feature Identity. Required memory and unchanged peer context reused from the preceding integration; new packaging, reviewer and integrator records read. No semantic conflict. Primary checkout and foreign work remain read-only. +- User explicitly requests distributed agents side by side, not fixed teacher/friend roles or a dropdown. Main owns topic history; Renderer uses existing APIs. Selection must not send paid questions, cancel accepted requests, rebind old versions or copy another agent's draft. Retain unassigned historical drafts visibly without guessing their owner. +- Plan: expose agent identity in topic list; connect catalog, topbar and per-agent panel; add focused switch/history/draft tests and existing Electron/layout regressions; run typecheck, scoped lint and build; record evidence and complete. No new subagents, push, merge, install or deployment in this task. + +## Outcome + +- Topbar renders all distributed agents with server avatars/names and a selected state; tabs scroll horizontally without overlapping window controls. The panel no longer contains an agent dropdown. Clicking an agent restores matching history or opens its empty composer; it does not create a topic or send a model request. +- Topic list projects the persisted definition's config_id as optional teacherId. Current-agent history restoration uses this identity, while the existing history menu still permits viewing older/other-agent topics and keeps their pinned version. Selecting a historical topic updates the topbar identity. No storage migration, Yuxi/WS change or billing change. +- Drafts are scoped by account/project/server agent ID. Switching or reopening preserves each agent's text/references; the former unassigned project draft remains readable in a collapsed section and is not guessed into another agent's input. Legacy friend drafts/history remain readable. +- Late initial history and stream/check-in callbacks cannot replace a later explicit agent selection. New empty agent selections wait for student input before participating in existing automatic check-ins; accepted runs are not cancelled. Unavailable historical agents remain viewable without silently choosing another model. +- README updated; visually inspected the real Electron fixture screenshot: both server-named entries appear side by side in the title bar and the panel selector is absent. + +## Verification + +- pnpm exec vitest run teacher-companion teacher-discussion teacher-cloud-activity coding-teacher --maxWorkers=1: 9 files / 305 passed. After simplifying retained selected-agent state, reran teacher-companion and coding-teacher-ui: 53 passed. Added one focused unassigned-draft regression afterward: 1 passed; remaining 35 cases intentionally filtered, not failures. +- Regression coverage includes per-agent drafts and explicit new-topic version, matching historical topic restoration without rebinding its version, late initial read after switching, no paid calls from switching, persisted Main topic identity and old draft retention. +- MAKELORE_LAYOUT_BROWSER_CHANNEL=msedge pnpm exec playwright test tests/e2e/teacher-preview-layout.spec.ts tests/e2e/teacher-discussion-layout.spec.ts: 17 passed. Includes eight-agent horizontal overflow at 520/1000px, keyboard access to the last agent, window-control clearance, bubble/preview placement and 319/508px discussion rendering. +- pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts --grep 'project consultations preserve': 1 real Electron fixture interaction passed. Exercises titlebar selection, no agent dropdown, independent drafts, correct version 9 creation, close/reopen, work/chat switching and no Pi prompt submission. Actual providers/online data are mocked. +- Renderer typecheck, scoped ESLint, build:vite (Renderer/Main/Preload/utility) and git diff --check passed. Installed pinned dependencies from the offline store and restored Electron through its normal installer; no dependency metadata, package version, existing user profile or installer was changed. +- Scope/diff self-review completed: only client UI, existing history DTO projection, README, focused tests and this record changed. No new subagent; existing approval was limited to the preceding rendering review. Main's previously recorded full-typecheck baseline issues were not claimed resolved by the standard Renderer check. + +## Follow-ups + +- Commit is ready for main integration. Not merged, pushed, packaged, installed or deployed in this task; the existing installed client does not yet include this change. Previously pending cleanup of the prior rendering worktrees is still unapproved and no worktree was removed. + +## Promotion Candidates + +- Target: teacher ADR, system overview/current-state. Proposal: backend-distributed agents appear as direct titlebar entries; selection restores the agent's history and scopes its drafts, while old topics keep their published version. Agent dropdown is removed; overflow scrolls within the titlebar. Evidence: explicit user screenshot/request, focused unit cases and real Electron/layout checks above. Future impact: no fixed role names or per-agent duplicated runtime; new agents follow the same catalog contract. Semantic conflict: replaces the former new-topic dropdown UX, expressly requested by user; billing, version and read-tool boundaries remain. Human confirmation: already supplied for implementation; canonical promotion awaits integration mode. diff --git a/README.md b/README.md index f66e672f..0702e660 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 文件预览最多 256 KiB,每次默认 60 行、最多 100 行及 2400 字节;长操作消息和智能体历史保留首尾节选及消息 ID,可按需读取本轮捕获的完整公开原文。每题最多六批本地读取;不上传 Pi 原始日志或思考。云端保留智能体对话及收到的片段,完整工程仍在本机。 - Code 课堂左侧项目栏默认宽 171px,可拖动右边缘在 128–256px 间调整,双击恢复默认并在本机记住宽度;标题栏同步对齐。项目名与会话名超长省略并可悬停查看;右侧咨询栏默认宽 508px,可拖动至 640px,紧凑窗口仍使用覆盖式咨询。 -- 客户端没有内置老师/朋友角色。标题栏与咨询面板使用服务端下发的名称、头像、简介、欢迎语和推荐问题;提供统一智能体目录与新话题入口。运营指定默认项,用户可为新话题选择其他已下发智能体。打开只读取配置和历史,不触发模型;咨询栏支持调整宽度和重新打开。 +- 客户端没有内置老师/朋友角色。已下发智能体以头像和名称并排显示在标题栏,点击后打开对应智能体的讨论;数量较多时横向滚动,咨询面板不再提供智能体下拉框。名称、头像、简介、欢迎语和推荐问题均由服务端提供。首次使用默认选择运营指定项,各智能体的未发送草稿分别保存,已有话题保持绑定的版本;旧的未分配项目草稿可展开查看,不自动转给其他智能体。打开或切换只读取配置和历史,不触发模型;咨询栏支持调整宽度和重新打开。 - 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 - 学生在当前项目创作时,前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成简短的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 - 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 diff --git a/electron/coding-teacher/store.ts b/electron/coding-teacher/store.ts index 75af717a..7614837b 100644 --- a/electron/coding-teacher/store.ts +++ b/electron/coding-teacher/store.ts @@ -38,6 +38,7 @@ export class TeacherTopicStore { || (topic.requests.some((request) => request.intent === 'check-in') ? '和智能体聊聊' : '新话题'), updatedAt: topic.updatedAt, version: topic.version, + teacherId: topic.definition.config_id, })) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return { diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index 33c3f1b0..41ac4d9d 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -131,7 +131,7 @@ export interface TeacherTopic { unsaved?: boolean; } export interface TeacherTopicList { - items: Array<{ id: string; title: string; updatedAt: string; version: number; legacyRole?: 'friend' }>; + items: Array<{ id: string; title: string; updatedAt: string; version: number; teacherId?: string; legacyRole?: 'friend' }>; lastSelectedTopicId: string | null; } export interface TeacherSend { diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 0d61238f..a33cb896 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -245,7 +245,8 @@ export function CodingChatPanel({ const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus); const teacherCompanion = useTeacherCompanion({ projectId: activeProject?.id ?? null, sourceId: targetConversationId, sourceBusy: running, sourceArchived: Boolean(selectedConversation?.archivedAt), teacherOpen: consultationOpen, teacherComposing }); const [teacherBubble, setTeacherBubble] = useState(null); - const openTeacher = () => { + const openTeacher = (agentId?: string) => { + if (agentId) teacherCompanion.selectAgent(agentId); const selectedText = window.getSelection()?.toString().trim(); setTeacherQuote(selectedText ? { kind: 'code', text: selectedText.slice(0, 12000) } : undefined); setTeacherQuoteSource(`${activeProject?.id}:${targetConversationId}`); @@ -947,20 +948,21 @@ export function CodingChatPanel({ {consultationOpen && activeProject && (
{ if (event.key === 'Escape') { setConsultationOpen(false); document.querySelector(`[aria-label="智能体"]`)?.focus(); } }}> + onKeyDown={(event) => { if (event.key === 'Escape') { setConsultationOpen(false); document.querySelector('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); } }}>
{ if (event.button === 0) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); } }} onPointerMove={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) setConsultationWidth(Math.max(320, Math.min(640, window.innerWidth - event.clientX))); }} onPointerUp={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }} onKeyDown={(event) => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); setConsultationWidth((width) => Math.max(320, Math.min(640, width + (event.key === 'ArrowLeft' ? 20 : -20)))); } }} /> - 508} onExpand={() => setConsultationWidth((width) => width > 508 ? 508 : 640)} quote={consultationOpen && teacherQuoteSource === `${activeProject.id}:${targetConversationId}` ? teacherQuote : undefined} - onClose={() => { setConsultationOpen(false); document.querySelector(`[aria-label="智能体"]`)?.focus(); }} /> + onClose={() => { setConsultationOpen(false); document.querySelector('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); }} />
)} diff --git a/src/pages/Chat/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx index 1eb88606..e09ee563 100644 --- a/src/pages/Chat/TeacherChatPanel.tsx +++ b/src/pages/Chat/TeacherChatPanel.tsx @@ -28,6 +28,7 @@ export function TeacherChatPanel({ externalTopic, onTopicChange, onComposingChange, + selectedAgent, }: { projectId: string; sourceId: string; @@ -42,17 +43,21 @@ export function TeacherChatPanel({ externalTopic?: TeacherTopic | null; onTopicChange?(topic: TeacherTopic): void; onComposingChange?(composing: boolean): void; + selectedAgent?: TeacherCatalog['items'][number]; }) { const base = teacherTopicsPath(projectId); const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out'); - // Retain the existing project draft key; its suffix is a storage identifier, not a role. - const draftKey = !draftRevision ? `makelore-consultation-draft:${account}:${projectId}:teacher` : null; + const projectDraftKey = `makelore-consultation-draft:${account}:${projectId}:teacher`; + const draftKey = !draftRevision ? selectedAgent ? `makelore-consultation-draft:${account}:${projectId}:agent:${selectedAgent.teacher_id}` : projectDraftKey : null; const [saved] = useState<{ text?: string; references?: TeacherReference[]; pending?: TeacherSend }>(() => { try { return draftKey ? JSON.parse(localStorage.getItem(draftKey) ?? '{}') : {}; } catch { return {}; } }); const [legacyDraft] = useState<{ text?: string; references?: TeacherReference[] }>(() => { try { return draftKey ? JSON.parse(localStorage.getItem(`makelore-consultation-draft:${account}:${projectId}:friend`) ?? '{}') : {}; } catch { return {}; } }); + const [unassignedDraft] = useState<{ text?: string; references?: TeacherReference[] }>(() => { + try { return selectedAgent ? JSON.parse(localStorage.getItem(projectDraftKey) ?? '{}') : {}; } catch { return {}; } + }); const persistDraft = useCallback((text: string, references: TeacherReference[], pending?: TeacherSend | null) => { if (!draftKey) return; try { localStorage.setItem(draftKey, JSON.stringify({ text, references, pending })); } catch { /* Draft remains in the open panel. */ } @@ -65,6 +70,9 @@ export function TeacherChatPanel({ const avatar = getAgentAvatarSrc(definition?.avatar_id); const externalTopicRef = useRef(externalTopic); externalTopicRef.current = externalTopic; + const selectedAgentId = selectedAgent?.teacher_id; + const selectedAgentRef = useRef(selectedAgent); + selectedAgentRef.current = selectedAgent; const inputRef = useRef(null); const focusedOnce = useRef(false); const [topics, setTopics] = useState({ items: [], lastSelectedTopicId: null }); @@ -118,11 +126,12 @@ export function TeacherChatPanel({ [persistDraft] ); useEffect(() => { - if (externalTopic && externalTopic.projectId === projectId) { + if (externalTopic && externalTopic.projectId === projectId + && (!selectedAgentId || externalTopic.definition.config_id === selectedAgentId)) { consume(externalTopic); setDefinition(externalTopic.definition); } - }, [externalTopic, projectId, consume]); + }, [externalTopic, projectId, consume, selectedAgentId]); useEffect(() => { if (topic) onTopicChange?.(topic); }, [topic, onTopicChange]); useEffect(() => { if (!busy && !focusedOnce.current) { @@ -150,10 +159,13 @@ export function TeacherChatPanel({ const catalog = await teacherApi.catalog(); if (alive) { setTeachers(catalog.items); - const selected = catalog.items.find((item) => item.is_default) ?? catalog.items[0]; + const selected = selectedAgentId + ? catalog.items.find((item) => item.teacher_id === selectedAgentId) + : catalog.items.find((item) => item.is_default) ?? catalog.items[0]; setSelectedTeacherVersion(selected?.version); - setEnabled(catalog.items.length > 0 || (config.enabled && !config.definition?.config_id)); - if (selected) setDefinition(selected.definition); + setEnabled(selectedAgentId ? Boolean(selected) : catalog.items.length > 0 || (config.enabled && !config.definition?.config_id)); + const visibleAgent = selected ?? selectedAgentRef.current; + if (visibleAgent) setDefinition(visibleAgent.definition); } } } catch (e) { @@ -163,11 +175,14 @@ export function TeacherChatPanel({ const listed = await teacherApi.list(base); if (!alive) return; setTopics(listed); - if (externalTopicRef.current) { + const previous = listed.items.find((item) => item.id === listed.lastSelectedTopicId + && (!selectedAgentId || item.teacherId === selectedAgentId)) + ?? (selectedAgentId ? listed.items.find((item) => item.teacherId === selectedAgentId) : undefined); + if (externalTopicRef.current && (!selectedAgentId || externalTopicRef.current.definition.config_id === selectedAgentId)) { consume(externalTopicRef.current); setDefinition(externalTopicRef.current.definition); - } else if (listed.lastSelectedTopicId) { - const current = await teacherApi.read(base, listed.lastSelectedTopicId); + } else if (previous) { + const current = await teacherApi.read(base, previous.id); if (alive && (!draftRevision || current.draftRevision === draftRevision)) { consume(current); setDefinition(current.definition); @@ -185,7 +200,7 @@ export function TeacherChatPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps generation.current++; }; - }, [base, draftRevision, label, consume]); + }, [base, draftRevision, label, consume, selectedAgentId]); useEffect(() => { if (!topic?.id || legacyFriend) return; const streamTopicId = topic.id; @@ -375,18 +390,6 @@ export function TeacherChatPanel({ {onExpand && } {onClose && } - {!draftRevision && teachers.length > 0 && ( - - )} {(topics.items.length > 0 || draftRevision) &&
{(historyOpen || draftRevision) &&
@@ -458,6 +461,13 @@ export function TeacherChatPanel({ {topic?.unsaved &&
回复尚未保存,请复制或重试保存。
} {!topicEnabled && !error && !busy &&

{legacyFriend ? '这是旧版内置朋友的话题,仅供查看。请选择智能体新建话题。' : `${label}暂未开放,历史仍可查看。`}

}
{ e.preventDefault(); void send(); }}> + {Boolean(unassignedDraft.text?.trim() || unassignedDraft.references?.length) &&
+ 查看之前的项目草稿 +
+ {unassignedDraft.text} + {unassignedDraft.references?.map((ref, index) =>
{ref.text}
)} +
+
} {Boolean(legacyDraft.text?.trim() || legacyDraft.references?.length) &&
查看旧版朋友草稿
diff --git a/src/pages/Chat/TeacherCompanion.tsx b/src/pages/Chat/TeacherCompanion.tsx index d11c4e38..a762b23a 100644 --- a/src/pages/Chat/TeacherCompanion.tsx +++ b/src/pages/Chat/TeacherCompanion.tsx @@ -7,23 +7,38 @@ type Companion = ReturnType; export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: { companion: Companion; open: boolean; - onOpen(): void; + onOpen(agentId?: string): void; bubbleRef?: Ref; }) { const { definition, invitation, welcome, loading, enabled, checking, error } = companion; const name = definition?.name || '智能体'; const avatar = getAgentAvatarSrc(definition?.avatar_id); + const activeId = companion.selectedAgent?.teacher_id; + const agents = companion.selectedAgent && !companion.agents.some((item) => item.teacher_id === activeId) + ? [...companion.agents, companion.selectedAgent] : companion.agents; const viewConversation = () => { companion.dismiss(); onOpen(); }; const status = loading ? '正在连接智能体…' : checking ? '智能体在看看你最近的进展…' : error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说'); return (
- )} + {(!agents.length || (!activeId && companion.topic)) && + } + {(invitation || welcome) &&
diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index 7e74fde5..c96d59bf 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -69,7 +69,9 @@ /* The teacher speaks from the title bar without taking the student's focus. */ .classroom-composer-view[hidden] { display: none; } -.teacher-companion { position: relative; flex-shrink: 0; } +.teacher-companion { position: relative; min-width: 0; max-width: 65%; } +.consultation-agent-tabs { display: flex; align-items: center; gap: 4px; max-width: 100%; overflow-x: auto; scrollbar-width: thin; } +.consultation-agent-tabs .teacher-companion-person { flex-shrink: 0; } .teacher-companion-person { display: flex; align-items: center; gap: 6px; height: 36px; padding: 0 8px; border-radius: 8px; color: #747d6c; transition: background 150ms; } .teacher-companion-person:hover, .teacher-companion-person[aria-expanded="true"] { background: #eef1e8; color: #42543a; } .teacher-companion-avatar { position: relative; flex-shrink: 0; } diff --git a/src/pages/Chat/use-teacher-companion.ts b/src/pages/Chat/use-teacher-companion.ts index 4b345a52..1a118be2 100644 --- a/src/pages/Chat/use-teacher-companion.ts +++ b/src/pages/Chat/use-teacher-companion.ts @@ -6,10 +6,13 @@ import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherCheckInInput, type TeacherDefinition, + type TeacherCatalog, type TeacherTopic, } from '../../../shared/coding-teacher'; interface CompanionState { + agents: TeacherCatalog['items']; + selectedAgent?: TeacherCatalog['items'][number]; definition: TeacherDefinition | null; enabled: boolean; loading: boolean; @@ -44,7 +47,8 @@ export function useTeacherCompanion(options: Options) { const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out'); const scope = `${account}:${options.projectId ?? ''}`; const storageKey = `makelore-teacher-presence:${scope}`; - const [state, setState] = useState({ definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: false, error: '' }); + const [state, setState] = useState({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: false, error: '' }); + const selectionEpoch = useRef(0); const latest = useRef({ options, state }); latest.current = { options, state }; const scopeRef = useRef(scope); @@ -55,8 +59,18 @@ export function useTeacherCompanion(options: Options) { if (scopeRef.current !== scope || topic.projectId !== options.projectId) return; setState((current) => current.topic?.id === topic.id && current.topic.revision >= topic.revision ? current - : { ...current, topic, error: '' }); + : { ...current, topic, selectedAgent: current.agents.find((item) => item.teacher_id === topic.definition.config_id) + ?? (topic.definition.config_id ? { teacher_id: topic.definition.config_id, version: topic.version, definition: topic.definition, is_default: false } : undefined), error: '' }); }, [scope, options.projectId]); + const selectAgent = useCallback((id: string) => { + const current = latest.current.state; + if (id === (current.selectedAgent?.teacher_id ?? current.topic?.definition.config_id ?? current.definition?.config_id)) return; + const agent = current.agents.find((item) => item.teacher_id === id); + if (!agent) return; + selectionEpoch.current++; + setState((value) => ({ ...value, selectedAgent: agent, definition: agent.definition, topic: null, + enabled: true, welcomeDismissed: true, error: '' })); + }, []); const dismiss = useCallback(() => { const request = unreadInvitation(latest.current.state); writeLocal(storageKey + ':welcome-dismissed', true); @@ -77,7 +91,9 @@ export function useTeacherCompanion(options: Options) { const storedSeen = readLocal(storageKey + ':seen', []); const seen = Array.isArray(storedSeen) ? storedSeen.filter((id): id is string => typeof id === 'string') : []; const welcomeDismissed = readLocal(storageKey + ':welcome-dismissed', false) === true; - setState({ definition: null, enabled: false, loading: true, topic: null, seen, welcomeDismissed, error: '' }); + selectionEpoch.current++; + const initialSelection = selectionEpoch.current; + setState({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen, welcomeDismissed, error: '' }); const projectId = options.projectId; if (!projectId) { setState((current) => ({ ...current, loading: false })); @@ -86,8 +102,14 @@ export function useTeacherCompanion(options: Options) { const base = teacherTopicsPath(projectId); const loadConfig = async () => { const config = await teacherApi.config(); - if (alive) setState((current) => ({ ...current, definition: config.definition, enabled: config.enabled, loading: false })); - return config; + const catalog = await teacherApi.catalog(); + const selectedId = latest.current.state.selectedAgent?.teacher_id; + const selected = catalog.items.find((item) => item.teacher_id === selectedId) + ?? (!selectedId ? catalog.items.find((item) => item.is_default) ?? catalog.items[0] : undefined); + const enabled = selected ? true : !selectedId && config.enabled; + if (alive) setState((current) => ({ ...current, agents: catalog.items, + definition: selected?.definition ?? current.selectedAgent?.definition ?? current.topic?.definition ?? config.definition, enabled, loading: false })); + return { enabled }; }; void (async () => { try { @@ -95,8 +117,8 @@ export function useTeacherCompanion(options: Options) { if (!alive) return; const topics = await teacherApi.list(base); if (!alive || !topics.lastSelectedTopicId) return; - const topic = await teacherApi.read(base, topics.lastSelectedTopicId); - if (alive && (!latest.current.state.topic || latest.current.state.topic.id === topic.id)) noteTopic(topic); + const topic = await teacherApi.read(base, topics.lastSelectedTopicId, false); + if (alive && initialSelection === selectionEpoch.current && (!latest.current.state.topic || latest.current.state.topic.id === topic.id)) noteTopic(topic); } catch { if (alive) setState((current) => ({ ...current, loading: false, error: '智能体暂时没连上,稍后再来看看。' })); } @@ -111,6 +133,8 @@ export function useTeacherCompanion(options: Options) { const { options: current, state: currentState } = latest.current; if (currentState.topic?.role === 'friend') return; if (!alive || inFlight.current || document.visibilityState !== 'visible' || currentState.loading) return; + // A newly selected agent has no Main selection until its history loads or the student sends. + if (currentState.selectedAgent && !currentState.topic) return; // A dropped event stream must not leave a completed reply stuck as thinking. if (currentState.topic?.requests.some((request) => ['preparing', 'running'].includes(request.status))) { inFlight.current = true; @@ -127,6 +151,7 @@ export function useTeacherCompanion(options: Options) { lastAttempt = Date.now(); writeLocal(storageKey + ':last-attempt', lastAttempt); inFlight.current = true; + const checkingSelection = selectionEpoch.current; try { const config = await loadConfig(); const dispatch = latest.current.options; @@ -143,7 +168,7 @@ export function useTeacherCompanion(options: Options) { writeLocal(storageKey + ':last-attempt', lastAttempt); } writeLocal(storageKey + ':pending', null); - if (result.topic) noteTopic(result.topic); + if (result.topic && checkingSelection === selectionEpoch.current) noteTopic(result.topic); } catch (error) { if (!alive) return; if (error instanceof AppError && typeof error.details?.status === 'number' && error.details.status >= 400 && error.details.status < 500 && error.details.status !== 408) writeLocal(storageKey + ':pending', null); @@ -171,12 +196,13 @@ export function useTeacherCompanion(options: Options) { if (!topicId || !topicProjectId || topic?.role === 'friend') return; let alive = true; let stream: EventSource | undefined; + const streamSelection = selectionEpoch.current; const base = teacherTopicsPath(topicProjectId); void teacherApi.events(base, topicId).then((source) => { if (!alive) { source.close(); return; } stream = source; source.addEventListener('snapshot', (event) => { - if (!alive) return; + if (!alive || streamSelection !== selectionEpoch.current) return; try { noteTopic(JSON.parse((event as MessageEvent).data) as TeacherTopic); } catch { /* Wait for the next authoritative snapshot. */ } }); }).catch(() => { /* The consultation can still be opened and reloaded. */ }); @@ -185,15 +211,20 @@ export function useTeacherCompanion(options: Options) { const last = topic?.requests.at(-1); const invitation = unreadInvitation({ topic, seen: state.seen }); - const visibleState = stateScope.current === scope ? state : { definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: true, error: '' }; + const visibleState = stateScope.current === scope ? state : { agents: [], selectedAgent: undefined, definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: true, error: '' }; // This is the published greeting, not a fabricated project assessment. const selectedDefinition = topic?.definition ?? visibleState.definition; + const selectedAgentId = visibleState.selectedAgent?.teacher_id ?? selectedDefinition?.config_id; + const selectedAgent = visibleState.agents.find((item) => item.teacher_id === selectedAgentId) + ?? visibleState.selectedAgent; const welcome = topic?.role !== 'friend' && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed ? selectedDefinition?.welcome_message.trim() || null : null; return { ...visibleState, error: last?.intent === 'check-in' && ['failed', 'interrupted'].includes(last.status) ? '智能体这次没连上,可以点头像找智能体聊聊。' : visibleState.error, definition: selectedDefinition, + selectedAgent, + selectAgent, topic, invitation, welcome, diff --git a/tests/e2e/fixtures/teacher-preview-layout.tsx b/tests/e2e/fixtures/teacher-preview-layout.tsx index c28999fe..9c6799df 100644 --- a/tests/e2e/fixtures/teacher-preview-layout.tsx +++ b/tests/e2e/fixtures/teacher-preview-layout.tsx @@ -13,7 +13,14 @@ export function Layout() { const [open, setOpen] = useState(false); const [width, setWidth] = useState(508); const [long, setLong] = useState(true); + const count = Number(new URLSearchParams(location.search).get('agents') ?? 0); + const [selected, setSelected] = useState(0); + 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' }, + })); const companion = { + agents, selectedAgent: agents[selected], definition: { name: '编程老师', avatar_id: 'avatar-06' }, invitation: visible ? { response: long ? '你刚才说想做一个宠物游戏。我们一起想想,什么样的小动作,会让你愿意一直陪它玩下去?可以先挑一个最想试的想法。' : '有什么编程问题想一起讨论?' } : null, welcome: null, enabled: true, loading: false, checking: false, error: null, @@ -21,9 +28,9 @@ export function Layout() { } as ComponentProps['companion']; return
- 布局测试项目 › hello - setOpen(!open)} bubbleRef={setBubble} /> - 朋友 + 布局测试项目 › hello + { if (id) setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(!open); }} bubbleRef={setBubble} /> + — □ ×
diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index b885b38e..2dbe2f38 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -539,7 +539,7 @@ async function installCodingFirstChatHost( const topicBase = `/${consultationPath}-topics`; const topicId = `${consultationPath}-topic`; const currentTopic = consultationTopics[consultationPath]; - if (path.endsWith(topicBase) && method === 'GET') return respond({items:currentTopic?[{id:currentTopic.id,title:'智能体话题'}]:[],lastSelectedTopicId:currentTopic?.id??null}); + if (path.endsWith(topicBase) && method === 'GET') return respond({items:currentTopic?[{id:currentTopic.id,title:'智能体话题',teacherId:(currentTopic.definition as {config_id?:string}).config_id}]:[],lastSelectedTopicId:currentTopic?.id??null}); if (path.endsWith(topicBase) && method === 'POST') { const selected = teachers.find(item => item.version === body?.teacherVersion) ?? teachers[0]; consultationTopics[consultationPath]={schemaVersion:1,revision:0,id:topicId,accountId:'e2e',projectId:project.id,sourceConversationId:conversation.id,definition:selected.definition,version:selected.version,createdAt:now,updatedAt:now,requests:[]}; @@ -1778,14 +1778,16 @@ test('project consultations preserve student drafts and switch between work and await expect(composer).toBeHidden(); await page.getByRole('tab',{name:'操作对话',exact:true}).click(); await expect(composer).toHaveValue('保留我的草稿'); - await page.getByRole('button',{name:'智能体',exact:true}).click(); + await page.getByRole('button',{name:'与朋友聊天',exact:true}).click(); const teacher=page.getByTestId('teacher-chat-panel'); await expect(page.getByRole('tab',{name:'作品',exact:true})).toHaveAttribute('aria-selected','true'); await expect(page.getByRole('tabpanel',{name:'作品',exact:true})).toBeVisible(); await expect(composer).toBeHidden(); await expect(composer).toHaveValue('保留我的草稿'); await expect(teacher.getByText('一起理解代码')).toBeVisible(); - await teacher.getByRole('combobox', { name: '新话题使用的智能体' }).selectOption('9'); + await expect(teacher.getByRole('combobox', { name: '新话题使用的智能体' })).toHaveCount(0); + await expect(page.getByRole('button', { name: '与代码智能体聊天' })).toBeVisible(); + await expect(page.getByRole('button', { name: '与朋友聊天' })).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('#coding-consultation-dock')).toHaveCSS('width', '508px'); await expect(teacher.getByTestId('consultation-composer-actions').getByRole('button', { name: '帮我看看', exact: true })).toBeVisible(); await expect(teacher.getByText('把你的困惑说出来就好')).toHaveCount(0); @@ -1810,8 +1812,11 @@ test('project consultations preserve student drafts and switch between work and await page.getByRole('tab',{name:'操作对话',exact:true}).click(); await expect(composer).toBeVisible(); await teacher.getByRole('button',{name:'关闭智能体'}).click(); - await expect(page.getByRole('button',{name:'朋友',exact:true})).toHaveCount(0); - await page.getByRole('button',{name:'智能体',exact:true}).click(); + await page.getByRole('button', { name: '与代码智能体聊天' }).click(); + await expect(teacher.getByRole('heading', { name: '代码智能体' })).toBeVisible(); + await expect(teacher.getByRole('textbox', { name: '向智能体提问' })).toHaveValue(''); + await teacher.getByRole('textbox', { name: '向智能体提问' }).fill('另一个智能体的草稿'); + await page.getByRole('button',{name:'与朋友聊天',exact:true}).click(); await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑'); await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible(); const requests=(await readState(electronApp)).captured; @@ -1826,7 +1831,7 @@ test('project consultations preserve student drafts and switch between work and expect(consultationRequests[2].body).toMatchObject({intent:'guided-help',text:'我也说不清,你带我看看'}); await page.screenshot({path:test.info().outputPath('project-teacher-side-chat.png')}); await teacher.getByRole('button',{name:'关闭智能体'}).click();await expect(teacher).toHaveCount(0); - await page.getByRole('button',{name:'智能体',exact:true}).click(); + await page.getByRole('button',{name:'与朋友聊天',exact:true}).click(); await expect(page.getByText('先理解状态如何随点击变化,再修改代码。',{exact:true})).toBeVisible(); await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑'); } finally {await releaseSnapshot(electronApp);} diff --git a/tests/e2e/teacher-preview-layout.spec.ts b/tests/e2e/teacher-preview-layout.spec.ts index 606921e8..d0e51130 100644 --- a/tests/e2e/teacher-preview-layout.spec.ts +++ b/tests/e2e/teacher-preview-layout.spec.ts @@ -23,7 +23,7 @@ test.beforeAll(async () => { }, configureServer(vite) { vite.middlewares.use((req, res, next) => { - if (req.url !== '/') return next(); + if (req.url !== '/' && !req.url?.startsWith('/?')) return next(); res.setHeader('Content-Type', 'text/html'); res.end('
'); }); @@ -35,6 +35,30 @@ test.beforeAll(async () => { }); test.afterAll(async () => { await server?.close(); }); +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 }); + await page.goto(baseUrl + '?agents=8'); + const tabs = page.getByRole('navigation', { name: '已下发智能体' }); + await expect(tabs.getByRole('button')).toHaveCount(8); + await expect(page.getByRole('combobox', { name: '新话题使用的智能体' })).toHaveCount(0); + const last = tabs.getByRole('button', { name: '与智能体8聊天' }); + await last.focus(); + await last.press('Enter'); + await expect(last).toHaveAttribute('aria-pressed', 'true'); + const bounds = await page.evaluate(() => { + const strip = document.querySelector('.consultation-agent-tabs')!; + const controls = document.querySelector('[data-testid="window-controls"]')!; + return { right: strip.getBoundingClientRect().right, controlsLeft: controls.getBoundingClientRect().left, + scroll: strip.scrollLeft, documentWidth: document.documentElement.scrollWidth }; + }); + expect(bounds.right).toBeLessThanOrEqual(bounds.controlsLeft); + expect(bounds.documentWidth).toBeLessThanOrEqual(width); + expect(bounds.scroll).toBeGreaterThan(0); + await page.screenshot({ path: test.info().outputPath('distributed-agents-titlebar.png') }); + }); +} + async function geometry(page: Page) { return page.evaluate(() => { const box = (selector: string) => document.querySelector(selector)!.getBoundingClientRect(); diff --git a/tests/unit/coding-teacher-ui.test.tsx b/tests/unit/coding-teacher-ui.test.tsx index dfefddbd..d8afe8b5 100644 --- a/tests/unit/coding-teacher-ui.test.tsx +++ b/tests/unit/coding-teacher-ui.test.tsx @@ -642,6 +642,22 @@ it('comparison focuses and directly sends the selected difference while preservi describe('merged cloud teacher classroom', () => { + it('keeps an old unassigned project draft readable without copying it to the selected agent', async () => { + const published = { ...definition, config_id: 'one' }; + const original = JSON.stringify({ text: '旧项目草稿', references: [{ kind: 'code', text: '旧引用' }] }); + const key = 'makelore-consultation-draft:signed-out:p:teacher'; + localStorage.setItem(key, original); + api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null }); + render(); + await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled()); + expect(screen.getByLabelText('向智能体提问')).toHaveValue(''); + fireEvent.click(screen.getByText('查看之前的项目草稿')); + expect(screen.getByText('旧项目草稿')).toBeVisible(); + fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '新的独立草稿' } }); + expect(localStorage.getItem(key)).toBe(original); + expect(api.send).not.toHaveBeenCalled(); + }); + it('selects a cloud teacher for the first project discussion without sending the operation draft', async () => { const cloudDefinition = { ...definition, runtime: 'yuxi' as const, config_id: 'algorithm', name: '算法智能体', yuxi: { agent_slug: 'algorithm', agent_version: 2 } }; api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null }); @@ -652,10 +668,10 @@ describe('merged cloud teacher classroom', () => { const selected = { ...topic('selected'), sourceConversationId: 'project', role: 'teacher' as const, definition: cloudDefinition, version: 9 }; api.create.mockResolvedValue(selected); api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({ ...selected, revision: 2, requests: [request({ id: input.requestId, text: input.text, response: '先看看玩家会做什么。' })] })); - render(); - await waitFor(() => expect(screen.getByLabelText('新话题使用的智能体')).toBeEnabled()); + render(); + await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled()); + expect(screen.queryByLabelText('新话题使用的智能体')).not.toBeInTheDocument(); expect(api.create).not.toHaveBeenCalled(); - fireEvent.change(screen.getByLabelText('新话题使用的智能体'), { target: { value: '9' } }); fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我想做一个小游戏' } }); fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })); await screen.findByText('先看看玩家会做什么。'); diff --git a/tests/unit/coding-teacher.test.ts b/tests/unit/coding-teacher.test.ts index ade3d073..baec5357 100644 --- a/tests/unit/coding-teacher.test.ts +++ b/tests/unit/coding-teacher.test.ts @@ -1405,6 +1405,8 @@ describe('project consultations with selected cloud teachers', () => { const topic = await f.service.create(scope, undefined, undefined, runtime === 'yuxi' ? 9 : undefined); expect(topic.role).toBeUndefined(); expect(topic.definition.teacher_id).toBe('coding-teacher'); + expect((await f.service.list(scope)).items.find((item) => item.id === topic.id)?.teacherId) + .toBe(runtime === 'yuxi' ? 'cloud-teacher' : undefined); await f.service.send(scope, topic.id, { requestId: crypto.randomUUID(), text: '你觉得怎么样?', sourceConversationId: f.scope.sourceId }); const messages = f.run.mock.calls[0][0]; expect(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT); diff --git a/tests/unit/teacher-companion.test.tsx b/tests/unit/teacher-companion.test.tsx index 51db41ea..9ed30e1a 100644 --- a/tests/unit/teacher-companion.test.tsx +++ b/tests/unit/teacher-companion.test.tsx @@ -6,7 +6,7 @@ import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion'; import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel'; import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher'; -const api = vi.hoisted(() => ({ config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn() })); +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() })); 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' } }) })); const definition: TeacherDefinition = { @@ -20,8 +20,8 @@ function Harness({ projectId = 'project', sourceId = 'source', busy = false, arc const [open, setOpen] = useState(false); const [teacherComposing, setTeacherComposing] = useState(false); const companion = useTeacherCompanion({ projectId, sourceId, sourceBusy: busy, sourceArchived: archived, teacherOpen: open, teacherComposing }); - return <> setOpen(true)} /> - {open && setOpen(false)} />}; + return <> { if (id) companion.selectAgent(id); setOpen(true); }} /> + {open && setOpen(false)} />}; } async function mount(props: Parameters[0] = {}) { let view!: ReturnType; @@ -35,6 +35,7 @@ beforeEach(() => { vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear(); Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); api.config.mockResolvedValue({ enabled: true, definition }); + api.catalog.mockResolvedValue({ items: [] }); api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null }); api.events.mockImplementation(async () => Object.assign(new EventTarget(), { close: vi.fn() })); api.checkIn.mockImplementation(async (projectId: string) => ({ topic: checkInTopic(projectId) })); @@ -42,6 +43,76 @@ beforeEach(() => { afterEach(() => { cleanup(); vi.useRealTimers(); }); describe('teacher presence and periodic guidance', () => { + it('shows every distributed agent in the header and preserves separate drafts without dispatching on switch', async () => { + const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } }; + const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友', avatar_id: 'avatar-09' } }; + api.catalog.mockResolvedValue({ items: [a, b] }); + api.create.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] }); + api.send.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] }); + await mount(); + expect(screen.getByRole('button', { name: '与方法老师聊天' })).toBeVisible(); + expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' }))); + expect(screen.queryByLabelText('新话题使用的智能体')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible(); + fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '体验的草稿' } }); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' }))); + expect(screen.getByLabelText('向智能体提问')).toHaveValue(''); + fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '方法的草稿' } }); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' }))); + expect(screen.getByLabelText('向智能体提问')).toHaveValue('体验的草稿'); + expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true'); + expect(api.create).not.toHaveBeenCalled(); + expect(api.send).not.toHaveBeenCalled(); + await advance(); + expect(api.checkIn).not.toHaveBeenCalled(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }))); + expect(api.create).toHaveBeenCalledWith('project', undefined, undefined, 9); + expect(api.send.mock.calls[0][2].text).toBe('体验的草稿'); + }); + + it('restores the selected agent history at its pinned version instead of the globally last topic', async () => { + const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } }; + const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } }; + const oldA = { ...checkInTopic(), id: 'a-topic', version: 1, definition: a.definition, requests: [] }; + const oldB = { ...checkInTopic(), id: 'b-topic', version: 8, definition: b.definition, requests: [] }; + api.catalog.mockResolvedValue({ items: [a, b] }); + api.list.mockResolvedValue({ items: [ + { id: oldB.id, teacherId: b.teacher_id, title: '体验历史' }, + { id: oldA.id, teacherId: a.teacher_id, title: '方法历史' }, + ], lastSelectedTopicId: oldB.id }); + api.read.mockImplementation(async (_base, id) => id === oldA.id ? oldA : oldB); + api.send.mockResolvedValue({ ...oldA, revision: 3 }); + await mount(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' }))); + expect(api.read).toHaveBeenCalledWith('project', oldA.id); + expect(screen.getByRole('heading', { name: '方法老师' })).toBeVisible(); + fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '继续原来的讨论' } }); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }))); + expect(api.send.mock.calls[0][1]).toBe(oldA.id); + expect(api.create).not.toHaveBeenCalled(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' }))); + expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible(); + expect(api.read).toHaveBeenCalledWith('project', oldB.id); + }); + + it('ignores a late initial history read after the student selects a different agent', async () => { + const a = { teacher_id: 'a', version: 1, is_default: true, definition: { ...definition, config_id: 'a', name: '方法老师' } }; + const b = { teacher_id: 'b', version: 2, is_default: false, definition: { ...definition, config_id: 'b', name: '体验朋友' } }; + const old = { ...checkInTopic(), definition: a.definition }; + api.catalog.mockResolvedValue({ items: [a, b] }); + api.list.mockResolvedValue({ items: [{ id: old.id, teacherId: 'a' }], lastSelectedTopicId: old.id }); + let resolve!: (topic: TeacherTopic) => void; + api.read.mockReturnValue(new Promise((done) => { resolve = done; })); + await mount(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' }))); + await act(async () => resolve(old)); + expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible(); + expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true'); + expect(api.read).toHaveBeenCalledWith('project', old.id, false); + expect(api.send).not.toHaveBeenCalled(); + }); + it('shows the selected agent greeting instead of borrowing the default agent greeting', async () => { api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '默认智能体欢迎语' } }); const selected = { ...checkInTopic(), requests: [], definition: { ...definition, name: '体验朋友', avatar_id: 'avatar-09', welcome_message: '我们聊聊你的体验' } };