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

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