feat: 在顶栏并排展示下发智能体并直接切换

This commit is contained in:
2026-09-24 15:38:50 +08:00
parent 39b03a1628
commit a10cf00015
15 changed files with 291 additions and 59 deletions

View File

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

View File

@@ -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 中止边界。

View File

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

View File

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

View File

@@ -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<HTMLDivElement | null>(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 && (
<div id="coding-consultation-dock" className="classroom-consultation-dock relative min-h-0 shrink-0 border-l" style={{ width: consultationWidth }}
onKeyDown={(event) => { if (event.key === 'Escape') { setConsultationOpen(false); document.querySelector<HTMLButtonElement>(`[aria-label="智能体"]`)?.focus(); } }}>
onKeyDown={(event) => { if (event.key === 'Escape') { setConsultationOpen(false); document.querySelector<HTMLButtonElement>('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); } }}>
<div role="separator" aria-label="调整咨询栏宽度" aria-orientation="vertical" aria-valuemin={320} aria-valuemax={640} aria-valuenow={consultationWidth} tabIndex={0} className="classroom-consultation-resizer"
onPointerDown={(event) => { 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)))); } }} />
<TeacherChatPanel key={`${account}:${activeProject.id}`}
<TeacherChatPanel key={`${account}:${activeProject.id}:${teacherCompanion.selectedAgent?.teacher_id ?? 'history'}`}
projectId={activeProject.id} projectName={activeProject.name} sourceId={targetConversationId ?? ''}
selectedAgent={teacherCompanion.selectedAgent}
externalTopic={teacherCompanion.topic}
onTopicChange={teacherCompanion.noteTopic}
onComposingChange={setTeacherComposing}
expanded={consultationWidth > 508} onExpand={() => setConsultationWidth((width) => width > 508 ? 508 : 640)}
quote={consultationOpen && teacherQuoteSource === `${activeProject.id}:${targetConversationId}` ? teacherQuote : undefined}
onClose={() => { setConsultationOpen(false); document.querySelector<HTMLButtonElement>(`[aria-label="智能体"]`)?.focus(); }} />
onClose={() => { setConsultationOpen(false); document.querySelector<HTMLButtonElement>('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); }} />
</div>
)}
</section>

View File

@@ -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<HTMLTextAreaElement>(null);
const focusedOnce = useRef(false);
const [topics, setTopics] = useState<TeacherTopicList>({ 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 && <button className="consult-icon" aria-label={expanded ? '恢复咨询栏宽度' : '展开咨询栏'} onClick={onExpand}><Maximize2 className="h-4 w-4" /></button>}
{onClose && <button className="consult-icon" aria-label={`关闭${label}`} onClick={onClose}><X className="h-4 w-4" /></button>}
</header>
{!draftRevision && teachers.length > 0 && (
<label className="px-5 pb-2 text-[11px] text-muted-foreground">
新话题使用的智能体
<select aria-label="新话题使用的智能体" value={selectedTeacherVersion ?? ''} disabled={busy}
onChange={(event) => { const selected = teachers.find(item => item.version === Number(event.target.value)); setSelectedTeacherVersion(selected?.version); if (!topic && selected) setDefinition(selected.definition); }}
className="mt-1 w-full rounded-lg border bg-background p-2 text-xs text-foreground">
{teachers.map((item) => <option key={item.teacher_id} value={item.version}>
{item.definition.name}{item.is_default ? '(默认)' : ''}
</option>)}
</select>
</label>
)}
{(topics.items.length > 0 || draftRevision) && <div className="px-5 pt-2">
<button className="text-[11px] text-muted-foreground" onClick={() => setHistoryOpen(!historyOpen)} aria-expanded={historyOpen}>以往讨论 <ChevronDown className="inline h-3 w-3" /></button>
{(historyOpen || draftRevision) && <div className="mt-2 flex gap-2">
@@ -458,6 +461,13 @@ export function TeacherChatPanel({
{topic?.unsaved && <div className="px-5 py-2 text-xs text-destructive">回复尚未保存,请复制或重试保存。<button className="ml-2 underline" onClick={() => void teacherApi.save(topicBase, topic.id).then(consume).catch(() => setError('保存失败,请先复制回复。'))}>重试保存</button></div>}
{!topicEnabled && !error && !busy && <p className="px-5 pb-2 text-xs text-muted-foreground">{legacyFriend ? '这是旧版内置朋友的话题,仅供查看。请选择智能体新建话题。' : `${label}暂未开放,历史仍可查看。`}</p>}
<form className="px-4 pb-4 pt-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
{Boolean(unassignedDraft.text?.trim() || unassignedDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
<summary>查看之前的项目草稿</summary>
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">
{unassignedDraft.text}
{unassignedDraft.references?.map((ref, index) => <pre className="mt-2 whitespace-pre-wrap" key={index}>{ref.text}</pre>)}
</div>
</details>}
{Boolean(legacyDraft.text?.trim() || legacyDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
<summary>查看旧版朋友草稿</summary>
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">

View File

@@ -7,23 +7,38 @@ type Companion = ReturnType<typeof useTeacherCompanion>;
export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
companion: Companion;
open: boolean;
onOpen(): void;
onOpen(agentId?: string): void;
bubbleRef?: Ref<HTMLDivElement>;
}) {
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 (
<section className="teacher-companion no-drag" aria-label="智能体陪伴" data-testid="teacher-companion">
<button type="button" className="teacher-companion-person" title={status} aria-label="智能体" aria-expanded={open} aria-controls="coding-consultation-dock" onMouseDown={(event) => event.preventDefault()} onClick={onOpen}>
<nav className="consultation-agent-tabs" aria-label="已下发智能体">
{agents.map((agent) => <button key={agent.teacher_id} type="button" className="teacher-companion-person"
title={agent.definition.description || agent.definition.name} aria-label={agents.length === 1 ? '智能体' : `与${agent.definition.name}聊天`}
aria-expanded={open && activeId === agent.teacher_id} aria-pressed={open && activeId === agent.teacher_id}
aria-controls="coding-consultation-dock" data-agent-id={agent.teacher_id}
onMouseDown={(event) => event.preventDefault()} onClick={() => onOpen(agent.teacher_id)}>
<span className="teacher-companion-avatar"><img src={getAgentAvatarSrc(agent.definition.avatar_id)} alt="" />
{activeId === agent.teacher_id && invitation && <span className="teacher-companion-unread" aria-hidden="true" />}</span>
<span className="teacher-companion-name">{agent.definition.name}</span>
{activeId === agent.teacher_id && checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
</button>)}
{(!agents.length || (!activeId && companion.topic)) && <button type="button" className="teacher-companion-person" title={status} aria-label="智能体" aria-expanded={open} aria-controls="coding-consultation-dock" onMouseDown={(event) => event.preventDefault()} onClick={() => onOpen()}>
<span className="teacher-companion-avatar"><img src={avatar} alt="" />{invitation && <span className="teacher-companion-unread" aria-hidden="true" />}</span>
<span className="teacher-companion-name">{name}</span>
{checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
</button>
</button>}
</nav>
{(invitation || welcome) && <div ref={bubbleRef} className="teacher-companion-presence" data-testid="teacher-presence">
<div className="teacher-companion-bubble" data-testid="teacher-invitation" data-bubble-kind={invitation ? 'check-in' : 'welcome'}>
<div className="teacher-companion-bubble-heading">

View File

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

View File

@@ -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<CompanionState>({ definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: false, error: '' });
const [state, setState] = useState<CompanionState>({ 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<unknown>(storageKey + ':seen', []);
const seen = Array.isArray(storedSeen) ? storedSeen.filter((id): id is string => typeof id === 'string') : [];
const welcomeDismissed = readLocal<boolean>(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,

View File

@@ -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<typeof TeacherCompanion>['companion'];
return <div className="classroom-workspace flex h-screen flex-col bg-white">
<header className="relative z-50 flex h-10 shrink-0 items-center justify-end border-b px-3" data-testid="coding-conversation-header">
<span className="mr-auto text-xs">布局测试项目 › hello</span>
<TeacherCompanion companion={companion} open={open} onOpen={() => setOpen(!open)} bubbleRef={setBubble} />
<span className="w-20 text-center text-xs">朋友</span>
<span className="mr-auto min-w-0 truncate text-xs">布局测试项目 › hello</span>
<TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(!open); }} bubbleRef={setBubble} />
<span data-testid="window-controls" className="w-[132px] shrink-0 text-center text-xs">— □ ×</span>
</header>
<div className="relative flex min-h-0 flex-1" data-testid="coding-chat-panel" data-consultation-open={open}>
<main className="flex min-w-0 flex-1 flex-col">

View File

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

View File

@@ -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('<html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/tests/e2e/fixtures/teacher-preview-layout.tsx"></script></body></html>');
});
@@ -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();

View File

@@ -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(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={{ teacher_id: 'one', version: 1, definition: published, is_default: true }} />);
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(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByLabelText('新话题使用的智能体')).toBeEnabled());
render(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={{ teacher_id: 'algorithm', version: 9, definition: cloudDefinition, is_default: false }} />);
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('先看看玩家会做什么。');

View File

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

View File

@@ -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 <><TeacherCompanion companion={companion} open={open} onOpen={() => setOpen(true)} />
{open && <TeacherChatPanel key={projectId} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} onClose={() => setOpen(false)} />}</>;
return <><TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) companion.selectAgent(id); setOpen(true); }} />
{open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} onClose={() => setOpen(false)} />}</>;
}
async function mount(props: Parameters<typeof Harness>[0] = {}) {
let view!: ReturnType<typeof render>;
@@ -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<TeacherTopic>((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: '我们聊聊你的体验' } };