feat(chat): show playful teacher introductions on hover

This commit is contained in:
鲨鱼辣椒
2026-09-28 12:12:10 +08:00
parent b26e25c9ed
commit 772da4f8e4
16 changed files with 433 additions and 62 deletions

View File

@@ -0,0 +1,52 @@
# Task: Show playful teacher introductions on hover and simplify the consultation header
## Identity
- Task ID: 20260928-teacher-hover-intro-a73f6e29
- Mode: Feature
- Branch: codex/20260928-teacher-hover-intro-a73f6e29-teacher-hover-intro
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore-teacher-hover-intro
- Base commit: b26e25c9ed26bf30f4d0bc03869c223f0fe34386
- Owner: codex
- Status: Ready for Integration
## Scope
- Move published teacher introductions into playful hover cards on the existing topbar entries. Remove introductions and the add/expand/close icon group from consultation headers.
## Intent And Constraints
- User explicitly requests this presentation/interaction change. Preserve published identities, teacher selection, conversation history, per-agent drafts, model/auth contracts and true proactive messages. Hover/focus must not start a conversation or acknowledge messages. Keep the existing single light visual system and native preview usable.
- Use the current entry to toggle its panel; selecting another teacher opens that teacher. Retain Escape and drag resizing. Keep explicit new topics as a text action inside expanded history, outside the header.
## Project Context Loaded
- Task ID/mode/branch/worktree/base match Identity and the successful start/status registry. All other registered task scopes reviewed through their records; prior classroom/style/integration tasks are completed sources. Main's teacher-focus task is active but owns prompt work, with no overlapping frontend files. Isolated ownership avoids contention.
- Read entry/planning instructions, memory index, active task, positioning, current state, teacher ADR/decision index, relevant architecture/domain/evidence and reflection/commitment/stale indexes. Current main uses generic server-distributed identities with separate drafts and pinned topic versions; older fixed-role task records are historical.
- Design skill search selected soft playful cards. Adapt cream/pale blue, rounded borders and restrained offset shadows to existing Chinese typography; no new font download or product-wide theme. Hover content uses the published description; no invented biography.
- Gate: Passed. Plan: implement tooltip and preview clearance, simplify the panel while preserving navigation, verify relevant unit tests, browser layout and isolated Electron interactions, then document delivery. No live teacher requests, remote publication or app restart in this task.
## Outcome
- Added a cream, rounded introduction card using the published teacher name, avatar and description. Hover or keyboard focus opens one card at a time outside the scrolling topbar; empty descriptions do not create a card. The pointer can enter the card to read long descriptions. Leaving, Escape or clicking the entry closes it; moving inside the clicked entry does not reopen it until a fresh entry.
- Removed the introduction and add/expand/close icon group from the consultation header. Clicking the selected topbar entry toggles its panel; switching entries selects that teacher. Escape restores focus to the selected teacher, per-agent drafts remain intact, drag resizing remains available, and the new-topic text action sits in expanded history.
- Extended native work preview clearance to include the introduction and any real unread speech bubble, using the maximum overlapping clearance. Hover does not create topics, send requests or acknowledge unread messages. README describes the final behavior.
- Independent review caught immediate entry-switch overlap and selected-entry focus restoration issues; both were fixed and covered. Final screenshot review prompted a second pointer-move opening-path regression, now fixed by keeping pointer-enter as the sole mouse opening path.
- During delivery, the peer teacher-focus task completed and a separate read-only agent-refresh task was registered. Its task record was reviewed; it changes no presentation/runtime code and has no semantic conflict.
- Implementation is isolated on the recorded branch. No live teacher calls, main merge, remote push or running trial update occurred.
## Verification
- Passed: 98 tests across teacher-companion, coding-teacher-ui, coding-chat-panel and preview-clearance suites on the final changes.
- Passed: 9 browser layout checks, including 520px/1000px scrolling teacher strips, direct teacher-to-teacher hover, keyboard focus/Escape, click then pointer movement, unread bubble preservation and native viewport clearance. Reviewed rendered card and desktop conversation screenshots.
- Passed: both targeted Electron scenarios (consultation drafts/teacher switching and topbar/proactive presence) against the final production build. One run failed before the teacher interaction at the existing sidebar drag assertion (193px instead of 256px); a separate retry of that scenario passed without source/test changes to the drag behavior.
- Passed: standard typecheck, changed-file ESLint, production Vite/Electron compilation, git diff whitespace check, project-docs structure and task-aware ownership drift checks.
- Independent code review passed. Electron tests use isolated profiles and fixture responses; no real-model interaction or installed-app deployment was exercised.
## Follow-ups
- Integrate this branch when requested; retain the worktree for review. The installed trial remains on its prior build.
## Promotion Candidates
- Target: `.project-docs/30-worklog/current-state.md` and the canonical classroom interaction description. Proposal: published teacher introductions live in transient topbar hover/focus cards, and consultation headers contain identity only; the selected entry toggles the panel and new topics remain available inside expanded history. Evidence: explicit user request, changed components, 98 unit tests, 9 browser layout checks and 2 Electron scenarios. Future impact: preserves the consultation capability while simplifying the teacher selection surface. Semantic conflicts: none with generic distributed identities, pinned topic versions or true proactive messages. Human confirmation: already supplied for this interaction; canonical promotion remains an integration-mode step.

View File

@@ -168,6 +168,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
- Code 课堂左侧项目栏默认宽 171px,可拖动右边缘在 128–256px 间调整,双击恢复默认并在本机记住宽度;标题栏同步对齐。项目名与会话名超长省略并可悬停查看;右侧咨询栏默认宽 508px,可拖动至 640px,紧凑窗口仍使用覆盖式咨询。
- 客户端没有内置老师/朋友角色。已下发智能体以头像和名称并排显示在标题栏,点击后打开对应智能体的讨论;数量较多时横向滚动,咨询面板不再提供智能体下拉框。名称、头像、简介、欢迎语和推荐问题均由服务端提供。首次使用默认选择运营指定项,各智能体的未发送草稿分别保存,已有话题保持绑定的版本;旧的未分配项目草稿可展开查看,不自动转给其他智能体。打开或切换只读取配置和历史,不触发模型;咨询栏支持调整宽度和重新打开。
- 鼠标悬停或键盘聚焦顶部智能体入口时,以奶油色圆角卡片显示该智能体的发布介绍;离开、Escape 或点击入口后收起,没有介绍则不显示空卡片。咨询标题区只保留头像和名称,移除介绍及新话题、展开、关闭三个图标。再次点击当前入口或在咨询栏按 Escape 可收起,切换其他入口仍直接打开对应讨论;拖动边缘调宽保留,新话题文字入口收在展开的“以往讨论”中。悬浮介绍不会创建话题、调用模型或确认未读消息,作品原生预览同时避让介绍卡和真实主动气泡。
- 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。
- 学生在当前项目创作时,前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成简短的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。
- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。

View File

@@ -47,6 +47,7 @@ export interface AgentBrowserPanelProps {
onOpenChange(open: boolean): void;
embedded?: boolean;
teacherBubble?: HTMLElement | null;
teacherIntroduction?: HTMLElement | null;
conversationId?: string | null;
onWorkConversation?(conversation: CodingConversationMetadata): void;
workRevision?: number;
@@ -133,6 +134,7 @@ export function AgentBrowserPanel({
onOpenChange,
embedded = false,
teacherBubble,
teacherIntroduction,
conversationId,
onWorkConversation,
workRevision,
@@ -149,7 +151,7 @@ export function AgentBrowserPanel({
const [events, setEvents] = useState<AgentBrowserCdpEvent[]>([]);
const viewportRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLElement | null>(null);
usePreviewClearance(panelRef, viewportRef, teacherBubble, embedded && open);
usePreviewClearance(panelRef, viewportRef, teacherBubble, embedded && open, teacherIntroduction);
const addressInputRef = useRef<HTMLInputElement | null>(null);
const eventCursorRef = useRef(0);
const snapshotGenerationRef = useRef(0);

View File

@@ -245,6 +245,7 @@ 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 [teacherIntroduction, setTeacherIntroduction] = useState<HTMLDivElement | null>(null);
const openTeacher = (agentId?: string) => {
if (agentId) teacherCompanion.selectAgent(agentId);
const selectedText = window.getSelection()?.toString().trim();
@@ -253,6 +254,19 @@ export function CodingChatPanel({
setConsultationOpen(true);
setAgentBrowserOpen(true);
};
const closeTeacher = () => {
setConsultationOpen(false);
const entry = document.querySelector<HTMLButtonElement>('.consultation-agent-tabs [aria-pressed="true"]')
?? document.querySelector<HTMLButtonElement>('.consultation-agent-tabs button');
entry?.focus();
};
const toggleTeacher = (agentId?: string) => {
if (consultationOpen && (!agentId || agentId === teacherCompanion.selectedAgent?.teacher_id)) {
closeTeacher();
return;
}
openTeacher(agentId);
};
const promptMode = running && draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt';
const entryLoadState = useCodingConversationStore((state) => (
targetConversationId
@@ -761,7 +775,7 @@ export function CodingChatPanel({
onToggleBrowser={() => setAgentBrowserOpen((current) => !current)}
projectName={activeProject?.name}
consultationOpen={consultationOpen}
teacherEntry={activeProject ? <TeacherCompanion companion={teacherCompanion} open={consultationOpen} onOpen={openTeacher} bubbleRef={setTeacherBubble} /> : undefined}
teacherEntry={activeProject ? <TeacherCompanion companion={teacherCompanion} open={consultationOpen} onOpen={openTeacher} onToggle={toggleTeacher} bubbleRef={setTeacherBubble} introductionRef={setTeacherIntroduction} /> : undefined}
/>
<div className="classroom-tabs" role="tablist" aria-label="当前工作">
@@ -771,6 +785,7 @@ export function CodingChatPanel({
<div id="classroom-work-view" role="tabpanel" aria-labelledby="classroom-work-tab" hidden={!agentBrowserOpen} className="classroom-work-view min-h-0 flex-1 flex-col p-5">
<AgentBrowserPanel projectId={activeProject?.id ?? null} open={agentBrowserOpen} onOpenChange={setAgentBrowserOpen} embedded
teacherBubble={teacherBubble}
teacherIntroduction={teacherIntroduction}
workRevision={workRevision}
onRecovery={handleRecovery}
onWorkResult={(result) => setReadyWorkProjectId(result.status === 'ready' ? activeProject?.id ?? null : null)}
@@ -948,7 +963,7 @@ 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>('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); } }}>
onKeyDown={(event) => { if (event.key === 'Escape') closeTeacher(); }}>
<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))); }}
@@ -960,9 +975,7 @@ export function CodingChatPanel({
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>('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); }} />
quote={consultationOpen && teacherQuoteSource === `${activeProject.id}:${targetConversationId}` ? teacherQuote : undefined} />
</div>
)}
</section>

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowUp, ChevronDown, ChevronRight, Loader2, Maximize2, Plus, X } from 'lucide-react';
import { ArrowUp, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores/auth';
import { teacherApi, teacherTopicsPath, legacyTopicBase } from '@/lib/coding-teacher';
import type {
@@ -18,13 +18,10 @@ import { TeacherReply } from './TeacherReply';
export function TeacherChatPanel({
projectId,
sourceId,
onClose,
onBringBack,
quote,
draftRevision,
sampleContext,
expanded,
onExpand,
externalTopic,
onTopicChange,
onComposingChange,
@@ -32,14 +29,11 @@ export function TeacherChatPanel({
}: {
projectId: string;
sourceId: string;
onClose?(): void;
onBringBack?(text: string): void;
quote?: TeacherReference;
draftRevision?: number;
sampleContext?: string;
projectName?: string;
expanded?: boolean;
onExpand?(): void;
externalTopic?: TeacherTopic | null;
onTopicChange?(topic: TeacherTopic): void;
onComposingChange?(composing: boolean): void;
@@ -384,10 +378,7 @@ export function TeacherChatPanel({
<header className="consultation-header flex h-[68px] shrink-0 items-center gap-3 px-5">
<TeacherAvatar className="consultation-avatar h-9 w-9 rounded-xl" definition={definition} />
<div className="min-w-0 flex-1"><h2 className="truncate text-sm font-semibold">{definition?.name ?? '智能体'}</h2>
<p className="mt-1 text-[11px] text-muted-foreground">{draftRevision ? '运营草稿试聊' : definition?.description}</p></div>
<button aria-label={`${label}新话题`} title="新话题" disabled={busy || !enabled} onClick={() => void newTopic()} className="consult-icon"><Plus className="h-4 w-4" /></button>
{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>}
{draftRevision && <p className="mt-1 text-[11px] text-muted-foreground">运营草稿试聊</p>}</div>
</header>
{(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>
@@ -395,6 +386,8 @@ export function TeacherChatPanel({
<select aria-label={`${label}话题`} value={topic?.id ?? ''} disabled={busy} onChange={(e) => void choose(e.target.value)} className="min-w-0 flex-1 rounded-lg border bg-background p-2 text-xs">
{!topic && <option value="">选择话题</option>}{topics.items.map((item) => <option key={item.id} value={item.id}>{item.title}{item.legacyRole ? '(旧版,仅查看)' : ''}</option>)}
</select>
<button type="button" aria-label={`${label}新话题`} disabled={busy || !enabled} onClick={() => void newTopic()}
className="shrink-0 rounded-lg px-2 text-xs text-muted-foreground hover:bg-white hover:text-foreground disabled:opacity-40">新话题</button>
</div>}
</div>}
{discussionEnabled && topic?.discussion && <TeacherDiscussionPanel key={topic.id} discussion={topic.discussion}

View File

@@ -1,16 +1,25 @@
import { ArrowUpRight, Loader2, X } from 'lucide-react';
import type { Ref } from 'react';
import { useState, type Ref } from 'react';
import { Provider as TooltipProvider } from '@radix-ui/react-tooltip';
import { TeacherAvatar } from './TeacherAvatar';
import { TeacherIntroduction } from './TeacherIntroduction';
import type { useTeacherCompanion } from './use-teacher-companion';
type Companion = ReturnType<typeof useTeacherCompanion>;
export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef, introductionRef }: {
companion: Companion;
open: boolean;
onOpen(agentId?: string): void;
onToggle?(agentId?: string): void;
bubbleRef?: Ref<HTMLDivElement>;
introductionRef?: Ref<HTMLDivElement>;
}) {
const { definition, invitation, welcome, loading, enabled, checking, error } = companion;
const [introductionId, setIntroductionId] = useState<string | null>(null);
const introductionProps = (id: string) => ({
open: introductionId === id,
onOpenChange: (next: boolean) => setIntroductionId((current) => next ? id : current === id ? null : current),
});
const name = definition?.name || '智能体';
const activeId = companion.selectedAgent?.teacher_id;
const agents = companion.selectedAgent && !companion.agents.some((item) => item.teacher_id === activeId)
@@ -20,23 +29,26 @@ export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
: checking ? '智能体在看看你最近的进展…'
: error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说');
return (
<TooltipProvider delayDuration={180} skipDelayDuration={100}>
<section className="teacher-companion no-drag" aria-label="智能体陪伴" data-testid="teacher-companion">
<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}聊天`}
{agents.map((agent) => <TeacherIntroduction key={agent.teacher_id} definition={agent.definition} contentRef={introductionRef} {...introductionProps(`agent:${agent.teacher_id}`)}>
<button type="button" className="teacher-companion-person"
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)}>
onMouseDown={(event) => event.preventDefault()} onClick={() => (onToggle ?? onOpen)(agent.teacher_id)}>
<span className="teacher-companion-avatar"><TeacherAvatar definition={agent.definition} />
{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()}>
</button></TeacherIntroduction>)}
{(!agents.length || (!activeId && companion.topic)) && <TeacherIntroduction definition={definition} contentRef={introductionRef} {...introductionProps('history')}>
<button type="button" className="teacher-companion-person" aria-description={status} aria-label="智能体" aria-expanded={open} aria-controls="coding-consultation-dock" onMouseDown={(event) => event.preventDefault()} onClick={() => (onToggle ?? onOpen)()}>
<span className="teacher-companion-avatar"><TeacherAvatar definition={definition} />{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></TeacherIntroduction>}
</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'}>
@@ -55,5 +67,6 @@ export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
</button>
</div>}
</section>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,43 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { Sparkles } from 'lucide-react';
import type { ReactElement, Ref } from 'react';
import type { TeacherDefinition } from '../../../shared/coding-teacher';
import { TeacherAvatar } from './TeacherAvatar';
/** A temporary introduction, separate from the agent's unread messages. */
export function TeacherIntroduction({ definition, children, contentRef, open, onOpenChange }: {
definition: TeacherDefinition | null;
children: ReactElement;
contentRef?: Ref<HTMLDivElement>;
open: boolean;
onOpenChange(open: boolean): void;
}) {
const description = definition?.description?.trim();
return <Tooltip.Root open={Boolean(description) && open} onOpenChange={onOpenChange}>
<Tooltip.Trigger asChild onPointerEnter={(event) => {
// Adjacent entries must switch immediately, even inside the previous
// tooltip's pointer grace area used when moving into its readable card.
if (event.pointerType !== 'touch') onOpenChange(true);
}} onPointerMove={(event) => {
// pointerEnter owns hover opening. Skip Radix's second opening path so
// moving inside an entry after clicking it cannot reopen its introduction.
if (event.pointerType !== 'touch') event.preventDefault();
}}>{children}</Tooltip.Trigger>
{definition && description && <Tooltip.Portal>
<Tooltip.Content ref={contentRef} side="bottom" align="end" sideOffset={10} collisionPadding={12}
className="teacher-introduction no-drag" data-testid="teacher-introduction" aria-label={description}>
<div className="teacher-introduction-heading">
<TeacherAvatar definition={definition} className="teacher-introduction-avatar" />
<div className="min-w-0 flex-1">
<span className="teacher-introduction-eyebrow">认识一下</span>
<p className="teacher-introduction-name">{definition.name}</p>
</div>
<Sparkles className="teacher-introduction-sparkles" aria-hidden="true" />
</div>
<p className="teacher-introduction-description">{description}</p>
<p className="teacher-introduction-hint">点点头像,一起聊聊</p>
<Tooltip.Arrow className="teacher-introduction-arrow" width={18} height={9} />
</Tooltip.Content>
</Tooltip.Portal>}
</Tooltip.Root>;
}

View File

@@ -70,6 +70,17 @@
/* The teacher speaks from the title bar without taking the student's focus. */
.classroom-composer-view[hidden] { display: none; }
.teacher-companion { position: relative; min-width: 0; max-width: 65%; }
.teacher-introduction { z-index: 200; width: min(304px, calc(100vw - 24px)); max-height: var(--radix-tooltip-content-available-height); padding: 18px; border: 2px solid #ead6af; border-radius: 22px; background: #fff9ed; color: #30465f; box-shadow: 0 5px 0 #ecdcc1, 0 12px 30px #354d6c18; transform-origin: var(--radix-tooltip-content-transform-origin); animation: teacher-introduction-in 150ms ease-out; }
.teacher-introduction-heading { display: flex; align-items: center; gap: 11px; }
.teacher-introduction-avatar { width: 48px; height: 48px; flex-shrink: 0; border: 2px solid #fff; border-radius: 15px; background: #e8f1fb; box-shadow: 0 3px 0 #d6e3ee; }
.teacher-introduction-eyebrow { display: block; margin-bottom: 3px; color: #856637; font-size: 11px; font-weight: 500; }
.teacher-introduction-name { overflow-wrap: anywhere; font-size: 15px; line-height: 1.5; font-weight: 600; }
.teacher-introduction-sparkles { align-self: flex-start; width: 19px; height: 19px; flex-shrink: 0; color: #b9842f; }
.teacher-introduction-description { max-height: min(240px, calc(var(--radix-tooltip-content-available-height) - 152px)); overflow-y: auto; overflow-wrap: anywhere; white-space: pre-wrap; margin-top: 16px; padding: 12px 13px; border: 1px solid #eee1c9; border-radius: 14px; background: #fffdf8; color: #48596e; font-size: 13px; line-height: 1.8; }
.teacher-introduction-hint { margin-top: 12px; color: #776649; font-size: 11px; text-align: center; }
.teacher-introduction-arrow { fill: #ead6af; }
@keyframes teacher-introduction-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .teacher-introduction { animation: none; } }
.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; }

View File

@@ -6,24 +6,27 @@ export function usePreviewClearance(
viewportRef: RefObject<HTMLElement | null>,
bubble: HTMLElement | null | undefined,
enabled: boolean,
introduction?: HTMLElement | null,
) {
useLayoutEffect(() => {
const panel = panelRef.current;
const viewport = viewportRef.current;
if (!enabled || !panel || !viewport || !bubble) return;
const overlays = [bubble, introduction].filter((item): item is HTMLElement => Boolean(item));
if (!enabled || !panel || !viewport || !overlays.length) return;
let applied = 0;
const update = () => {
const message = bubble.getBoundingClientRect();
const page = viewport.getBoundingClientRect();
// Subtract our own previous offset so ResizeObserver cannot create a
// feedback loop that alternately adds and removes the same clearance.
const naturalTop = page.top - applied;
const overlapsHorizontally = message.left < page.right && message.right > page.left;
const visible = message.width > 0 && message.height > 0 && page.width > 0;
const next = visible && overlapsHorizontally && message.top < page.bottom
? Math.max(0, Math.ceil(message.bottom + 8 - naturalTop))
: 0;
const next = Math.max(0, ...overlays.map((overlay) => {
const message = overlay.getBoundingClientRect();
const overlapsHorizontally = message.left < page.right && message.right > page.left;
const visible = message.width > 0 && message.height > 0 && page.width > 0;
return visible && overlapsHorizontally && message.top < page.bottom
? Math.ceil(message.bottom + 8 - naturalTop) : 0;
}));
if (next === applied) return;
applied = next;
panel.style.marginTop = next ? `${next}px` : '';
@@ -31,7 +34,7 @@ export function usePreviewClearance(
update();
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
observer?.observe(bubble);
overlays.forEach((overlay) => observer?.observe(overlay));
observer?.observe(viewport);
window.addEventListener('resize', update);
return () => {
@@ -39,5 +42,5 @@ export function usePreviewClearance(
window.removeEventListener('resize', update);
panel.style.marginTop = '';
};
}, [bubble, enabled, panelRef, viewportRef]);
}, [bubble, introduction, enabled, panelRef, viewportRef]);
}

View File

@@ -9,7 +9,8 @@ import '../../../src/pages/Chat/classroom-workspace.css';
// window is launched, and no user project, provider, or live Host API is used.
export function Layout() {
const [bubble, setBubble] = useState<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(true);
const [introduction, setIntroduction] = useState<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(!new URLSearchParams(location.search).has('quiet'));
const [open, setOpen] = useState(false);
const [width, setWidth] = useState(508);
const [long, setLong] = useState(true);
@@ -18,6 +19,7 @@ export function Layout() {
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',
description: `我是智能体${index + 1},陪你把脑洞变成小作品。一起发现有趣的问题,也一起想清楚下一步怎么做。`,
icon_url: new URLSearchParams(location.search).has('icons') ? `https://yuxi.example.test/api/storage/public/images/agent-${index}.png` : undefined },
}));
const companion = {
@@ -30,14 +32,17 @@ export function Layout() {
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 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} />
<TeacherCompanion companion={companion} open={open}
onOpen={(id) => { if (id) setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(true); }}
onToggle={(id) => { if (!id || id === agents[selected]?.teacher_id) setOpen(!open); else { setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(true); } }}
bubbleRef={setBubble} introductionRef={setIntroduction} />
<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">
<div className="classroom-tabs" role="tablist"><button role="tab">操作对话</button><button role="tab" aria-selected="true">作品</button></div>
<div className="classroom-work-view flex min-h-0 flex-1 flex-col p-5" data-testid="work-view">
<AgentBrowserPanel projectId="layout-fixture" embedded open teacherBubble={bubble} onOpenChange={() => {}} />
<AgentBrowserPanel projectId="layout-fixture" embedded open teacherBubble={bubble} teacherIntroduction={introduction} onOpenChange={() => {}} />
</div>
</main>
{open && <aside id="coding-consultation-dock" className="classroom-consultation-dock consultation-pane shrink-0 border-l p-5" style={{ width }}>

View File

@@ -454,10 +454,10 @@ async function installCodingFirstChatHost(
...overrides,
});
const teacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
const teacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'一起发现代码里的小奥秘,把想法变成作品。',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
const teachers = [
{ teacher_id: 'teacher-code', version: 1, is_default: true, definition: { ...teacherDefinition, config_id: 'teacher-code', runtime: 'yuxi', system_prompt: '', yuxi: { agent_slug: 'code', agent_version: 2 } } },
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '朋友', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '朋友', description: '陪你听听玩家的想法,让作品更有趣。', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
];
const consultationTopics: Record<string, Record<string, unknown>> = {};
ipcMain.removeHandler('hostapi:fetch');
@@ -1786,8 +1786,18 @@ 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}).hover();
await expect(page.getByTestId('teacher-introduction')).toContainText('一起发现代码里的小奥秘,把想法变成作品。');
await page.getByRole('button',{name:'与朋友聊天',exact:true}).hover();
await expect(page.getByTestId('teacher-introduction')).toHaveCount(1);
await expect(page.getByTestId('teacher-introduction')).toContainText('陪你听听玩家的想法,让作品更有趣。');
await expect(page.getByTestId('teacher-chat-panel')).toHaveCount(0);
expect((await readState(electronApp)).captured.filter(item => /\/(messages|teacher-check-in)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
const teacher=page.getByTestId('teacher-chat-panel');
await expect(page.getByTestId('teacher-introduction')).toHaveCount(0);
await expect(teacher.locator('header').getByRole('button')).toHaveCount(0);
await expect(teacher.getByText('陪你听听玩家的想法,让作品更有趣。')).toHaveCount(0);
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();
@@ -1819,7 +1829,9 @@ test('project consultations preserve student drafts and switch between work and
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
await expect(composer).toBeVisible();
await teacher.getByRole('button',{name:'关闭智能体'}).click();
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
await expect(teacher).toHaveCount(0);
await expect(page.getByRole('button',{name:'与朋友聊天',exact:true})).toBeFocused();
await page.getByRole('button', { name: '与代码智能体聊天' }).click();
await expect(teacher.getByRole('heading', { name: '代码智能体' })).toBeVisible();
await expect(teacher.getByRole('textbox', { name: '向智能体提问' })).toHaveValue('');
@@ -1827,6 +1839,11 @@ test('project consultations preserve student drafts and switch between work and
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
// Moving inside the clicked entry must not schedule its introduction again.
const friendEntry = (await page.getByRole('button', { name: '与朋友聊天', exact: true }).boundingBox())!;
await page.mouse.move(friendEntry.x + friendEntry.width / 2 + 2, friendEntry.y + friendEntry.height / 2);
await page.waitForTimeout(300);
await expect(page.getByTestId('teacher-introduction')).toHaveCount(0);
const requests=(await readState(electronApp)).captured;
expect(requests.filter(item=>item.path.endsWith('/prompt')&&item.method==='POST')).toHaveLength(0);
const consultationRequests=requests.filter(item=>item.path.endsWith('/messages')&&item.method==='POST');
@@ -1838,7 +1855,7 @@ test('project consultations preserve student drafts and switch between work and
expect(consultationRequests[1].body?.intent).toBeUndefined();
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 expect(teacher).toHaveCount(0);
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
await expect(page.getByText('先理解状态如何随点击变化,再修改代码。',{exact:true})).toBeVisible();
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
@@ -1900,9 +1917,9 @@ test('teacher stays in the top-right header and proactively checks in without ta
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const header = page.getByTestId('coding-conversation-header');
const companion = header.getByTestId('teacher-companion');
const teacherButton = companion.getByRole('button', { name: '智能体', exact: true });
const header = page.getByTestId('coding-conversation-header-titlebar');
const companion = page.getByTestId('teacher-companion');
const teacherButton = companion.getByRole('button', { name: '与代码智能体聊天', exact: true });
await expect(teacherButton).toHaveText('代码智能体');
await expect(teacherButton).toBeVisible();
await expect(teacherButton.locator('img')).toHaveAttribute('src', /avatar-01/);
@@ -1926,6 +1943,22 @@ test('teacher stays in the top-right header and proactively checks in without ta
await composer.fill('我还在写自己的想法');
await page.getByRole('tab', { name: '作品', exact: true }).click();
await expect(teacherButton).toBeVisible();
await teacherButton.hover();
await page.clock.runFor(250);
const introduction = page.getByTestId('teacher-introduction');
await expect(introduction).toContainText('一起发现代码里的小奥秘,把想法变成作品。');
await expect(introduction).toBeInViewport({ ratio: 1 });
const [introductionBounds, introductionViewportBounds] = await Promise.all([
introduction.boundingBox(), page.getByTestId('agent-browser-viewport').boundingBox(),
]);
// Native WebContentsView must leave room for the portaled introduction too.
expect(introductionBounds).not.toBeNull();
expect(introductionViewportBounds).not.toBeNull();
expect(introductionViewportBounds!.y).toBeGreaterThanOrEqual(introductionBounds!.y + introductionBounds!.height);
await page.keyboard.press('Escape');
await expect(introduction).toHaveCount(0);
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
expect((await readState(electronApp)).captured.filter(item => /\/(messages|teacher-check-in)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
await composer.focus();
await page.clock.fastForward(300_000);
@@ -1977,9 +2010,10 @@ test('teacher stays in the top-right header and proactively checks in without ta
const teacherInput = teacher.getByRole('textbox', { name: '向智能体提问' });
await expect(teacherInput).toBeFocused();
await teacherInput.fill('我想比谁更了解自己的宠物');
await teacher.getByRole('button', { name: '关闭智能体' }).click();
await expect(page.getByRole('button', { name: '智能体', exact: true })).toBeFocused();
await page.getByRole('button', { name: '智能体', exact: true }).click();
await teacherInput.press('Escape');
await expect(teacher).toHaveCount(0);
await expect(teacherButton).toBeFocused();
await teacherButton.click();
await expect(teacherInput).toHaveValue('我想比谁更了解自己的宠物');
await teacherInput.fill('');
// Let the cleared draft propagate through the parent before advancing the check-in timer.

View File

@@ -70,6 +70,76 @@ async function geometry(page: Page) {
});
}
for (const width of [520, 1000]) {
test(`teacher introduction floats outside the scrolling strip and clears the work at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await page.goto(`${baseUrl}?agents=8&quiet=1`);
const first = page.getByRole('button', { name: '与智能体1聊天', exact: true });
const card = page.getByTestId('teacher-introduction');
await expect(first).not.toHaveAttribute('title');
await first.hover();
await expect(card).toBeVisible();
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体1');
await page.getByRole('button', { name: '与智能体2聊天', exact: true }).hover();
await expect(card).toHaveCount(1);
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体2');
await first.hover();
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体1');
await expect.poll(async () => page.evaluate(() => {
const card = document.querySelector('[data-testid="teacher-introduction"]')!.getBoundingClientRect();
const viewport = document.querySelector('[data-testid="agent-browser-viewport"]')!.getBoundingClientRect();
return card.left >= 0 && card.right <= innerWidth && card.bottom <= innerHeight
&& (card.left >= viewport.right || card.right <= viewport.left || viewport.top - card.bottom >= 8);
})).toBe(true);
await card.hover();
await expect(card).toBeVisible();
await page.screenshot({ path: test.info().outputPath('teacher-hover-introduction.png') });
const cardBounds = (await card.boundingBox())!;
await page.screenshot({ path: test.info().outputPath('teacher-introduction-card.png'), clip: {
x: Math.max(0, cardBounds.x - 12), y: cardBounds.y - 12,
width: cardBounds.width + 24, height: cardBounds.height + 30,
} });
await page.mouse.move(0, 780);
await expect(card).toHaveCount(0);
await expect(page.getByTestId('agent-browser-panel')).toHaveCSS('margin-top', '0px');
const last = page.getByRole('button', { name: '与智能体8聊天', exact: true });
await last.focus();
await expect(card).toBeVisible();
await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体8');
await last.press('Escape');
await expect(card).toHaveCount(0);
await expect(last).toBeFocused();
await last.hover();
await expect(card).toBeVisible();
await last.click();
await expect(card).toHaveCount(0);
await expect(page.locator('#coding-consultation-dock')).toBeVisible();
const lastBounds = (await last.boundingBox())!;
await page.mouse.move(lastBounds.x + lastBounds.width / 2 + 2, lastBounds.y + lastBounds.height / 2);
await page.waitForTimeout(300);
await expect(card).toHaveCount(0);
await page.mouse.move(0, 780);
await last.hover();
await expect(card).toBeVisible();
await last.click();
await expect(page.locator('#coding-consultation-dock')).toHaveCount(0);
});
}
test('closing an introduction preserves the unread speech and its native preview clearance', async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 720 });
await page.goto(`${baseUrl}?agents=2`);
await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8);
const original = (await geometry(page)).clearance;
await page.getByRole('button', { name: '与智能体2聊天', exact: true }).hover();
await expect(page.getByTestId('teacher-introduction')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByTestId('teacher-introduction')).toHaveCount(0);
await expect(page.getByTestId('teacher-invitation')).toBeVisible();
await expect.poll(async () => (await geometry(page)).clearance).toBe(original);
});
test('distributed icons load over HTTP and broken images fall back in the titlebar', async ({ page }) => {
await page.route('https://yuxi.example.test/api/storage/public/images/agent-0.png', route => route.fulfill({
contentType: 'image/svg+xml',

View File

@@ -306,12 +306,14 @@ describe('CodingChatPanel first Conversation', () => {
fireEvent.click(chatTab);
expect(operationInput).toBeVisible();
expect(teacherInput).toHaveValue('怎样判断规则清不清楚?');
fireEvent.click(screen.getByRole('button', { name: '关闭智能体' }));
fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }));
expect(screen.queryByRole('textbox', { name: '向智能体提问' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '朋友', exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }));
expect(screen.getByRole('textbox', { name: '向智能体提问' })).toHaveValue('怎样判断规则清不清楚?');
fireEvent.click(screen.getByRole('button', { name: '关闭智能体' }));
fireEvent.keyDown(screen.getByRole('textbox', { name: '向智能体提问' }), { key: 'Escape' });
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveFocus();
expect(consultationCreate).not.toHaveBeenCalled();
expect(consultationSend).not.toHaveBeenCalled();
expect(conversationApi.submit).not.toHaveBeenCalled();

View File

@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import type { TeacherDefinition, TeacherRequest, TeacherSend, TeacherTopic } from '../../shared/coding-teacher';
@@ -96,6 +96,24 @@ async function ready() {
await waitFor(() => expect(streams.has('first')).toBe(true));
}
describe('teacher side chat', () => {
it('keeps the panel header focused on identity and puts new topics inside expanded history', async () => {
render(<TeacherChatPanel projectId="p" sourceId="c" />);
const history = await screen.findByRole('button', { name: '以往讨论' });
const header = screen.getByTestId('teacher-chat-panel').querySelector('header')!;
expect(within(header).getByRole('heading', { name: '智能体' })).toBeVisible();
expect(within(header).queryByRole('button')).not.toBeInTheDocument();
expect(screen.queryByText('服务端简介')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '智能体新话题' })).not.toBeInTheDocument();
fireEvent.click(history);
const newTopic = screen.getByRole('button', { name: '智能体新话题' });
expect(newTopic).toBeEnabled();
expect(newTopic).toHaveTextContent('新话题');
expect(header.contains(newTopic)).toBe(false);
fireEvent.click(newTopic);
await waitFor(() => expect(api.create).toHaveBeenCalledWith('p/agent-topics', undefined, undefined, 1));
expect(api.send).not.toHaveBeenCalled();
});
it('renders the pinned topic icon in the header and assistant messages', async () => {
const icon = 'https://yuxi.example.test/api/storage/public/images/topic.png';
api.read.mockResolvedValue({ ...first, definition: { ...definition, icon_url: icon }, requests: [request({ response: '继续原来的讨论' })] });
@@ -221,16 +239,15 @@ describe('teacher side chat', () => {
expect(screen.getByLabelText('向智能体提问')).toHaveValue('解释代码');
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('教学回答');
expect(screen.getByText('服务端简介')).toBeVisible();
expect(screen.queryByText('服务端简介')).not.toBeInTheDocument();
expect(screen.getByText('较长的上下文已节选,智能体可按需读取原文。')).toBeVisible();
expect(api.send.mock.calls[0][2].requestId).toBe(api.send.mock.calls[1][2].requestId);
expect(bringBack).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '带回主会话草稿' }));
expect(bringBack).toHaveBeenCalledWith('教学回答');
});
it('ignores an old topic stream after switching and does not cancel when closed', async () => {
const closed = vi.fn();
const view = render(<TeacherChatPanel projectId="p" sourceId="c" onClose={closed} />);
it('ignores an old topic stream after switching and does not cancel when the panel unmounts', async () => {
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const old = streams.get('first')!;
fireEvent.change(screen.getByRole('combobox', { name: '智能体话题' }), {
@@ -245,8 +262,6 @@ describe('teacher side chat', () => {
)
);
expect(screen.getByRole('combobox', { name: '智能体话题' })).toHaveValue('second');
fireEvent.click(screen.getByRole('button', { name: '关闭智能体' }));
expect(closed).toHaveBeenCalledOnce();
view.unmount();
expect(api.cancel).not.toHaveBeenCalled();
expect(old.close).toHaveBeenCalled();
@@ -297,7 +312,8 @@ describe('teacher side chat', () => {
api.catalog.mockResolvedValue({ items: [{ teacher_id: 'published', version: 2, is_default: true, definition: published }] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await screen.findByRole('heading', { name, exact: true });
expect(screen.getByText('这是运营发布的简介')).toBeVisible();
expect(screen.queryByText('这是运营发布的简介')).not.toBeInTheDocument();
expect(within(screen.getByTestId('teacher-chat-panel').querySelector('header')!).queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByText('服务端欢迎语')).toBeVisible();
expect(screen.getByRole('button', { name: '服务端推荐问题' })).toBeEnabled();
expect(screen.getByTestId('teacher-chat-panel').querySelector('img')?.src).toContain('avatar-09.png');
@@ -681,6 +697,8 @@ describe('merged cloud teacher classroom', () => {
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(screen.queryByRole('button', { name: '以往讨论' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '智能体新话题' })).not.toBeInTheDocument();
expect(api.create).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我想做一个小游戏' } });
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));

View File

@@ -6,19 +6,22 @@ import { usePreviewClearance } from '@/pages/Chat/use-preview-clearance';
let resize: () => void;
let bubbleRect: DOMRect;
let pageRect: DOMRect;
let introductionRect: DOMRect;
const bubble = document.createElement('div');
const introduction = document.createElement('div');
const rect = (x: number, y: number, width: number, height: number) => new DOMRect(x, y, width, height);
function Harness({ visible = true, open = true }: { visible?: boolean; open?: boolean }) {
function Harness({ visible = true, open = true, intro = false }: { visible?: boolean; open?: boolean; intro?: boolean }) {
const panel = useRef<HTMLDivElement>(null);
const viewport = useRef<HTMLDivElement>(null);
usePreviewClearance(panel, viewport, visible ? bubble : null, open);
usePreviewClearance(panel, viewport, visible ? bubble : null, open, intro ? introduction : null);
return <div ref={panel} data-testid="panel"><div ref={viewport} data-testid="viewport" /></div>;
}
beforeEach(() => {
bubbleRect = rect(960, 48, 340, 164);
pageRect = rect(192, 184, 1200, 600);
introductionRect = rect(950, 50, 304, 290);
vi.stubGlobal('ResizeObserver', class {
constructor(callback: () => void) { resize = callback; }
observe() {}
@@ -26,6 +29,7 @@ beforeEach(() => {
});
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this === bubble) return bubbleRect;
if (this === introduction) return introductionRect;
const offset = Number.parseFloat(this.parentElement?.style.marginTop ?? '') || 0;
return rect(pageRect.x, pageRect.y + offset, pageRect.width, pageRect.height - offset);
});
@@ -73,3 +77,22 @@ it('removes clearance when the bubble is dismissed or Work closes', () => {
view.rerender(<Harness open={false} />);
expect(screen.getByTestId('panel').style.marginTop).toBe('');
});
it('clears a hovered introduction even when no proactive bubble exists', () => {
const view = render(<Harness visible={false} intro />);
expect(screen.getByTestId('panel').style.marginTop).toBe('164px');
act(() => { resize(); resize(); });
expect(screen.getByTestId('panel').style.marginTop).toBe('164px');
view.rerender(<Harness visible={false} intro={false} />);
expect(screen.getByTestId('panel').style.marginTop).toBe('');
});
it('keeps the deeper overlapping surface clear without adding both heights', () => {
const view = render(<Harness intro />);
expect(screen.getByTestId('panel').style.marginTop).toBe('164px');
introductionRect = rect(950, 50, 304, 100);
act(() => resize());
expect(screen.getByTestId('panel').style.marginTop).toBe('36px');
view.rerender(<Harness intro={false} />);
expect(screen.getByTestId('panel').style.marginTop).toBe('36px');
});

View File

@@ -20,8 +20,12 @@ 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={(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)} />}</>;
return <><TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) companion.selectAgent(id); setOpen(true); }}
onToggle={(id) => {
if (open && (!id || id === companion.selectedAgent?.teacher_id)) setOpen(false);
else { 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} />}</>;
}
async function mount(props: Parameters<typeof Harness>[0] = {}) {
let view!: ReturnType<typeof render>;
@@ -33,6 +37,11 @@ async function advance(ms = TEACHER_CHECK_IN_INTERVAL_MS) {
}
beforeEach(() => {
vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear();
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
disconnect() {}
});
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
api.config.mockResolvedValue({ enabled: true, definition });
api.catalog.mockResolvedValue({ items: [] });
@@ -40,9 +49,88 @@ beforeEach(() => {
api.events.mockImplementation(async () => Object.assign(new EventTarget(), { close: vi.fn() }));
api.checkIn.mockImplementation(async (projectId: string) => ({ topic: checkInTopic(projectId) }));
});
afterEach(() => { cleanup(); vi.useRealTimers(); });
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
describe('teacher presence and periodic guidance', () => {
it('shows the hovered agent introduction in a portal without selecting it or sending a request', async () => {
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师', description: '把大问题拆成小积木,一起试试看。' } };
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友', description: '陪你听听玩家的想法,让作品更有趣。' } };
api.catalog.mockResolvedValue({ items: [a, b] });
const view = await mount();
const method = screen.getByRole('button', { name: '与方法老师聊天' });
const experience = screen.getByRole('button', { name: '与体验朋友聊天' });
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
fireEvent.pointerEnter(method, { pointerType: 'mouse' });
await advance(250);
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(a.definition.description);
expect(view.container.contains(screen.getByTestId('teacher-introduction'))).toBe(false);
fireEvent.pointerLeave(method, { pointerType: 'mouse' });
fireEvent.pointerMove(document.body, { pointerType: 'mouse', clientX: 100, clientY: 100 });
fireEvent.pointerEnter(experience, { pointerType: 'mouse' });
await advance(250);
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(b.definition.description);
expect(screen.getByTestId('teacher-introduction')).not.toHaveTextContent(a.definition.description);
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
expect(experience).toHaveAttribute('aria-pressed', 'false');
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
await act(async () => fireEvent.click(experience));
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
// The first move may arrive only after the click when crossing a tooltip's
// grace area. It must not reopen the introduction beneath the pointer.
fireEvent.pointerMove(experience, { pointerType: 'mouse' });
await advance(250);
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
fireEvent.pointerLeave(experience, { pointerType: 'mouse' });
fireEvent.pointerMove(document.body, { pointerType: 'mouse', clientX: 100, clientY: 100 });
fireEvent.pointerEnter(experience, { pointerType: 'mouse' });
await advance(250);
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(b.definition.description);
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('opens an introduction on keyboard focus and Escape leaves an unread invitation unacknowledged', async () => {
const unread = { ...checkInTopic(), definition: { ...definition, description: '一起发现代码里的小奥秘。' } };
api.list.mockResolvedValue({ items: [{ id: unread.id }], lastSelectedTopicId: unread.id });
api.read.mockResolvedValue(unread);
const view = await mount();
const button = screen.getByRole('button', { name: '智能体', exact: true });
await act(async () => button.focus());
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(unread.definition.description);
fireEvent.keyDown(button, { key: 'Escape' });
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'check-in');
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
view.unmount();
await mount();
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('does not show an empty introduction and preserves a draft when the same entry closes and reopens the panel', async () => {
await mount();
const button = screen.getByRole('button', { name: '智能体', exact: true });
fireEvent.pointerEnter(button, { pointerType: 'mouse' });
await advance(250);
await act(async () => button.focus());
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
await act(async () => fireEvent.click(button));
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我还没想好怎么问' } });
await act(async () => fireEvent.click(button));
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
expect(button).toHaveAttribute('aria-expanded', 'false');
await act(async () => fireEvent.click(button));
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我还没想好怎么问');
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
});
it('uses each published icon in the topbar, welcome and panel, and falls back after an image error', async () => {
const a = { teacher_id: 'a', version: 1, is_default: true, definition: { ...definition, icon_url: 'https://yuxi.example.test/api/storage/public/images/a.png' } };
const b = { teacher_id: 'b', version: 2, is_default: false, definition: { ...definition, name: '另一个智能体', icon_url: 'https://yuxi.example.test/api/storage/public/images/b.webp' } };