diff --git a/.project-docs/30-worklog/tasks/20260928-teacher-hover-intro-a73f6e29.md b/.project-docs/30-worklog/tasks/20260928-teacher-hover-intro-a73f6e29.md new file mode 100644 index 00000000..892ff923 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-teacher-hover-intro-a73f6e29.md @@ -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. diff --git a/README.md b/README.md index 70ceb548..f6353a5d 100644 --- a/README.md +++ b/README.md @@ -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 中止边界。 diff --git a/src/pages/Chat/AgentBrowserPanel.tsx b/src/pages/Chat/AgentBrowserPanel.tsx index 54b997c1..bc8e0220 100644 --- a/src/pages/Chat/AgentBrowserPanel.tsx +++ b/src/pages/Chat/AgentBrowserPanel.tsx @@ -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([]); const viewportRef = useRef(null); const panelRef = useRef(null); - usePreviewClearance(panelRef, viewportRef, teacherBubble, embedded && open); + usePreviewClearance(panelRef, viewportRef, teacherBubble, embedded && open, teacherIntroduction); const addressInputRef = useRef(null); const eventCursorRef = useRef(0); const snapshotGenerationRef = useRef(0); diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index a33cb896..7418bd1b 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -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(null); + const [teacherIntroduction, setTeacherIntroduction] = useState(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('.consultation-agent-tabs [aria-pressed="true"]') + ?? document.querySelector('.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 ? : undefined} + teacherEntry={activeProject ? : undefined} />
@@ -771,6 +785,7 @@ export function CodingChatPanel({ } {discussionEnabled && topic?.discussion && ; -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; + introductionRef?: Ref; }) { const { definition, invitation, welcome, loading, enabled, checking, error } = companion; + const [introductionId, setIntroductionId] = useState(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 ( +
{(invitation || welcome) &&
@@ -55,5 +67,6 @@ export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
}
+
); } diff --git a/src/pages/Chat/TeacherIntroduction.tsx b/src/pages/Chat/TeacherIntroduction.tsx new file mode 100644 index 00000000..3cf16ef5 --- /dev/null +++ b/src/pages/Chat/TeacherIntroduction.tsx @@ -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; + open: boolean; + onOpenChange(open: boolean): void; +}) { + const description = definition?.description?.trim(); + return + { + // 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} + {definition && description && + +
+ +
+ 认识一下 +

{definition.name}

+
+
+

{description}

+

点点头像,一起聊聊

+ +
+
} +
; +} diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index c96d59bf..de0bf644 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -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; } diff --git a/src/pages/Chat/use-preview-clearance.ts b/src/pages/Chat/use-preview-clearance.ts index 74c94cfd..f0f9051b 100644 --- a/src/pages/Chat/use-preview-clearance.ts +++ b/src/pages/Chat/use-preview-clearance.ts @@ -6,24 +6,27 @@ export function usePreviewClearance( viewportRef: RefObject, 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]); } diff --git a/tests/e2e/fixtures/teacher-preview-layout.tsx b/tests/e2e/fixtures/teacher-preview-layout.tsx index d5440515..011500d6 100644 --- a/tests/e2e/fixtures/teacher-preview-layout.tsx +++ b/tests/e2e/fixtures/teacher-preview-layout.tsx @@ -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(null); - const [visible, setVisible] = useState(true); + const [introduction, setIntroduction] = useState(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
布局测试项目 › hello - { if (id) setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(!open); }} bubbleRef={setBubble} /> + { 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} /> — □ ×
- {}} /> + {}} />
{open &&