feat: 在顶栏并排展示下发智能体并直接切换
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# Task: Show distributed consultation agents in title bar
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260924-consultation-topbar-22717e99
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260924-consultation-topbar-22717e99-consultation-topbar
|
||||
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260924-consultation-topbar-22717e99
|
||||
- Base commit: 39b03a1628bcdbc9c6bcf82b874e2814bf1780d0
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Show every server-distributed consultation agent in the Code title bar with avatar/name and direct selection; remove the panel's new-topic agent dropdown. Preserve pinned topic versions, isolate drafts/history by agent and fit narrow windows.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Concurrent/Planning Gates Passed: official check/start/status match isolated feature Identity. Required memory and unchanged peer context reused from the preceding integration; new packaging, reviewer and integrator records read. No semantic conflict. Primary checkout and foreign work remain read-only.
|
||||
- User explicitly requests distributed agents side by side, not fixed teacher/friend roles or a dropdown. Main owns topic history; Renderer uses existing APIs. Selection must not send paid questions, cancel accepted requests, rebind old versions or copy another agent's draft. Retain unassigned historical drafts visibly without guessing their owner.
|
||||
- Plan: expose agent identity in topic list; connect catalog, topbar and per-agent panel; add focused switch/history/draft tests and existing Electron/layout regressions; run typecheck, scoped lint and build; record evidence and complete. No new subagents, push, merge, install or deployment in this task.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Topbar renders all distributed agents with server avatars/names and a selected state; tabs scroll horizontally without overlapping window controls. The panel no longer contains an agent dropdown. Clicking an agent restores matching history or opens its empty composer; it does not create a topic or send a model request.
|
||||
- Topic list projects the persisted definition's config_id as optional teacherId. Current-agent history restoration uses this identity, while the existing history menu still permits viewing older/other-agent topics and keeps their pinned version. Selecting a historical topic updates the topbar identity. No storage migration, Yuxi/WS change or billing change.
|
||||
- Drafts are scoped by account/project/server agent ID. Switching or reopening preserves each agent's text/references; the former unassigned project draft remains readable in a collapsed section and is not guessed into another agent's input. Legacy friend drafts/history remain readable.
|
||||
- Late initial history and stream/check-in callbacks cannot replace a later explicit agent selection. New empty agent selections wait for student input before participating in existing automatic check-ins; accepted runs are not cancelled. Unavailable historical agents remain viewable without silently choosing another model.
|
||||
- README updated; visually inspected the real Electron fixture screenshot: both server-named entries appear side by side in the title bar and the panel selector is absent.
|
||||
|
||||
## Verification
|
||||
|
||||
- pnpm exec vitest run teacher-companion teacher-discussion teacher-cloud-activity coding-teacher --maxWorkers=1: 9 files / 305 passed. After simplifying retained selected-agent state, reran teacher-companion and coding-teacher-ui: 53 passed. Added one focused unassigned-draft regression afterward: 1 passed; remaining 35 cases intentionally filtered, not failures.
|
||||
- Regression coverage includes per-agent drafts and explicit new-topic version, matching historical topic restoration without rebinding its version, late initial read after switching, no paid calls from switching, persisted Main topic identity and old draft retention.
|
||||
- MAKELORE_LAYOUT_BROWSER_CHANNEL=msedge pnpm exec playwright test tests/e2e/teacher-preview-layout.spec.ts tests/e2e/teacher-discussion-layout.spec.ts: 17 passed. Includes eight-agent horizontal overflow at 520/1000px, keyboard access to the last agent, window-control clearance, bubble/preview placement and 319/508px discussion rendering.
|
||||
- pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts --grep 'project consultations preserve': 1 real Electron fixture interaction passed. Exercises titlebar selection, no agent dropdown, independent drafts, correct version 9 creation, close/reopen, work/chat switching and no Pi prompt submission. Actual providers/online data are mocked.
|
||||
- Renderer typecheck, scoped ESLint, build:vite (Renderer/Main/Preload/utility) and git diff --check passed. Installed pinned dependencies from the offline store and restored Electron through its normal installer; no dependency metadata, package version, existing user profile or installer was changed.
|
||||
- Scope/diff self-review completed: only client UI, existing history DTO projection, README, focused tests and this record changed. No new subagent; existing approval was limited to the preceding rendering review. Main's previously recorded full-typecheck baseline issues were not claimed resolved by the standard Renderer check.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Commit is ready for main integration. Not merged, pushed, packaged, installed or deployed in this task; the existing installed client does not yet include this change. Previously pending cleanup of the prior rendering worktrees is still unapproved and no worktree was removed.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: teacher ADR, system overview/current-state. Proposal: backend-distributed agents appear as direct titlebar entries; selection restores the agent's history and scopes its drafts, while old topics keep their published version. Agent dropdown is removed; overflow scrolls within the titlebar. Evidence: explicit user screenshot/request, focused unit cases and real Electron/layout checks above. Future impact: no fixed role names or per-agent duplicated runtime; new agents follow the same catalog contract. Semantic conflict: replaces the former new-topic dropdown UX, expressly requested by user; billing, version and read-tool boundaries remain. Human confirmation: already supplied for implementation; canonical promotion awaits integration mode.
|
||||
@@ -164,7 +164,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
|
||||
- 文件预览最多 256 KiB,每次默认 60 行、最多 100 行及 2400 字节;长操作消息和智能体历史保留首尾节选及消息 ID,可按需读取本轮捕获的完整公开原文。每题最多六批本地读取;不上传 Pi 原始日志或思考。云端保留智能体对话及收到的片段,完整工程仍在本机。
|
||||
|
||||
- Code 课堂左侧项目栏默认宽 171px,可拖动右边缘在 128–256px 间调整,双击恢复默认并在本机记住宽度;标题栏同步对齐。项目名与会话名超长省略并可悬停查看;右侧咨询栏默认宽 508px,可拖动至 640px,紧凑窗口仍使用覆盖式咨询。
|
||||
- 客户端没有内置老师/朋友角色。标题栏与咨询面板使用服务端下发的名称、头像、简介、欢迎语和推荐问题;提供统一智能体目录与新话题入口。运营指定默认项,用户可为新话题选择其他已下发智能体。打开只读取配置和历史,不触发模型;咨询栏支持调整宽度和重新打开。
|
||||
- 客户端没有内置老师/朋友角色。已下发智能体以头像和名称并排显示在标题栏,点击后打开对应智能体的讨论;数量较多时横向滚动,咨询面板不再提供智能体下拉框。名称、头像、简介、欢迎语和推荐问题均由服务端提供。首次使用默认选择运营指定项,各智能体的未发送草稿分别保存,已有话题保持绑定的版本;旧的未分配项目草稿可展开查看,不自动转给其他智能体。打开或切换只读取配置和历史,不触发模型;咨询栏支持调整宽度和重新打开。
|
||||
- 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。
|
||||
- 学生在当前项目创作时,前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成简短的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。
|
||||
- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。
|
||||
|
||||
@@ -38,6 +38,7 @@ export class TeacherTopicStore {
|
||||
|| (topic.requests.some((request) => request.intent === 'check-in') ? '和智能体聊聊' : '新话题'),
|
||||
updatedAt: topic.updatedAt,
|
||||
version: topic.version,
|
||||
teacherId: topic.definition.config_id,
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return {
|
||||
|
||||
@@ -131,7 +131,7 @@ export interface TeacherTopic {
|
||||
unsaved?: boolean;
|
||||
}
|
||||
export interface TeacherTopicList {
|
||||
items: Array<{ id: string; title: string; updatedAt: string; version: number; legacyRole?: 'friend' }>;
|
||||
items: Array<{ id: string; title: string; updatedAt: string; version: number; teacherId?: string; legacyRole?: 'friend' }>;
|
||||
lastSelectedTopicId: string | null;
|
||||
}
|
||||
export interface TeacherSend {
|
||||
|
||||
@@ -245,7 +245,8 @@ export function CodingChatPanel({
|
||||
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
|
||||
const teacherCompanion = useTeacherCompanion({ projectId: activeProject?.id ?? null, sourceId: targetConversationId, sourceBusy: running, sourceArchived: Boolean(selectedConversation?.archivedAt), teacherOpen: consultationOpen, teacherComposing });
|
||||
const [teacherBubble, setTeacherBubble] = useState<HTMLDivElement | null>(null);
|
||||
const openTeacher = () => {
|
||||
const openTeacher = (agentId?: string) => {
|
||||
if (agentId) teacherCompanion.selectAgent(agentId);
|
||||
const selectedText = window.getSelection()?.toString().trim();
|
||||
setTeacherQuote(selectedText ? { kind: 'code', text: selectedText.slice(0, 12000) } : undefined);
|
||||
setTeacherQuoteSource(`${activeProject?.id}:${targetConversationId}`);
|
||||
@@ -947,20 +948,21 @@ export function CodingChatPanel({
|
||||
|
||||
{consultationOpen && activeProject && (
|
||||
<div id="coding-consultation-dock" className="classroom-consultation-dock relative min-h-0 shrink-0 border-l" style={{ width: consultationWidth }}
|
||||
onKeyDown={(event) => { if (event.key === 'Escape') { setConsultationOpen(false); document.querySelector<HTMLButtonElement>(`[aria-label="智能体"]`)?.focus(); } }}>
|
||||
onKeyDown={(event) => { if (event.key === 'Escape') { setConsultationOpen(false); document.querySelector<HTMLButtonElement>('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); } }}>
|
||||
<div role="separator" aria-label="调整咨询栏宽度" aria-orientation="vertical" aria-valuemin={320} aria-valuemax={640} aria-valuenow={consultationWidth} tabIndex={0} className="classroom-consultation-resizer"
|
||||
onPointerDown={(event) => { if (event.button === 0) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); } }}
|
||||
onPointerMove={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) setConsultationWidth(Math.max(320, Math.min(640, window.innerWidth - event.clientX))); }}
|
||||
onPointerUp={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }}
|
||||
onKeyDown={(event) => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); setConsultationWidth((width) => Math.max(320, Math.min(640, width + (event.key === 'ArrowLeft' ? 20 : -20)))); } }} />
|
||||
<TeacherChatPanel key={`${account}:${activeProject.id}`}
|
||||
<TeacherChatPanel key={`${account}:${activeProject.id}:${teacherCompanion.selectedAgent?.teacher_id ?? 'history'}`}
|
||||
projectId={activeProject.id} projectName={activeProject.name} sourceId={targetConversationId ?? ''}
|
||||
selectedAgent={teacherCompanion.selectedAgent}
|
||||
externalTopic={teacherCompanion.topic}
|
||||
onTopicChange={teacherCompanion.noteTopic}
|
||||
onComposingChange={setTeacherComposing}
|
||||
expanded={consultationWidth > 508} onExpand={() => setConsultationWidth((width) => width > 508 ? 508 : 640)}
|
||||
quote={consultationOpen && teacherQuoteSource === `${activeProject.id}:${targetConversationId}` ? teacherQuote : undefined}
|
||||
onClose={() => { setConsultationOpen(false); document.querySelector<HTMLButtonElement>(`[aria-label="智能体"]`)?.focus(); }} />
|
||||
onClose={() => { setConsultationOpen(false); document.querySelector<HTMLButtonElement>('.consultation-agent-tabs [aria-pressed="true"], .consultation-agent-tabs button')?.focus(); }} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -28,6 +28,7 @@ export function TeacherChatPanel({
|
||||
externalTopic,
|
||||
onTopicChange,
|
||||
onComposingChange,
|
||||
selectedAgent,
|
||||
}: {
|
||||
projectId: string;
|
||||
sourceId: string;
|
||||
@@ -42,17 +43,21 @@ export function TeacherChatPanel({
|
||||
externalTopic?: TeacherTopic | null;
|
||||
onTopicChange?(topic: TeacherTopic): void;
|
||||
onComposingChange?(composing: boolean): void;
|
||||
selectedAgent?: TeacherCatalog['items'][number];
|
||||
}) {
|
||||
const base = teacherTopicsPath(projectId);
|
||||
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
|
||||
// Retain the existing project draft key; its suffix is a storage identifier, not a role.
|
||||
const draftKey = !draftRevision ? `makelore-consultation-draft:${account}:${projectId}:teacher` : null;
|
||||
const projectDraftKey = `makelore-consultation-draft:${account}:${projectId}:teacher`;
|
||||
const draftKey = !draftRevision ? selectedAgent ? `makelore-consultation-draft:${account}:${projectId}:agent:${selectedAgent.teacher_id}` : projectDraftKey : null;
|
||||
const [saved] = useState<{ text?: string; references?: TeacherReference[]; pending?: TeacherSend }>(() => {
|
||||
try { return draftKey ? JSON.parse(localStorage.getItem(draftKey) ?? '{}') : {}; } catch { return {}; }
|
||||
});
|
||||
const [legacyDraft] = useState<{ text?: string; references?: TeacherReference[] }>(() => {
|
||||
try { return draftKey ? JSON.parse(localStorage.getItem(`makelore-consultation-draft:${account}:${projectId}:friend`) ?? '{}') : {}; } catch { return {}; }
|
||||
});
|
||||
const [unassignedDraft] = useState<{ text?: string; references?: TeacherReference[] }>(() => {
|
||||
try { return selectedAgent ? JSON.parse(localStorage.getItem(projectDraftKey) ?? '{}') : {}; } catch { return {}; }
|
||||
});
|
||||
const persistDraft = useCallback((text: string, references: TeacherReference[], pending?: TeacherSend | null) => {
|
||||
if (!draftKey) return;
|
||||
try { localStorage.setItem(draftKey, JSON.stringify({ text, references, pending })); } catch { /* Draft remains in the open panel. */ }
|
||||
@@ -65,6 +70,9 @@ export function TeacherChatPanel({
|
||||
const avatar = getAgentAvatarSrc(definition?.avatar_id);
|
||||
const externalTopicRef = useRef(externalTopic);
|
||||
externalTopicRef.current = externalTopic;
|
||||
const selectedAgentId = selectedAgent?.teacher_id;
|
||||
const selectedAgentRef = useRef(selectedAgent);
|
||||
selectedAgentRef.current = selectedAgent;
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const focusedOnce = useRef(false);
|
||||
const [topics, setTopics] = useState<TeacherTopicList>({ items: [], lastSelectedTopicId: null });
|
||||
@@ -118,11 +126,12 @@ export function TeacherChatPanel({
|
||||
[persistDraft]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (externalTopic && externalTopic.projectId === projectId) {
|
||||
if (externalTopic && externalTopic.projectId === projectId
|
||||
&& (!selectedAgentId || externalTopic.definition.config_id === selectedAgentId)) {
|
||||
consume(externalTopic);
|
||||
setDefinition(externalTopic.definition);
|
||||
}
|
||||
}, [externalTopic, projectId, consume]);
|
||||
}, [externalTopic, projectId, consume, selectedAgentId]);
|
||||
useEffect(() => { if (topic) onTopicChange?.(topic); }, [topic, onTopicChange]);
|
||||
useEffect(() => {
|
||||
if (!busy && !focusedOnce.current) {
|
||||
@@ -150,10 +159,13 @@ export function TeacherChatPanel({
|
||||
const catalog = await teacherApi.catalog();
|
||||
if (alive) {
|
||||
setTeachers(catalog.items);
|
||||
const selected = catalog.items.find((item) => item.is_default) ?? catalog.items[0];
|
||||
const selected = selectedAgentId
|
||||
? catalog.items.find((item) => item.teacher_id === selectedAgentId)
|
||||
: catalog.items.find((item) => item.is_default) ?? catalog.items[0];
|
||||
setSelectedTeacherVersion(selected?.version);
|
||||
setEnabled(catalog.items.length > 0 || (config.enabled && !config.definition?.config_id));
|
||||
if (selected) setDefinition(selected.definition);
|
||||
setEnabled(selectedAgentId ? Boolean(selected) : catalog.items.length > 0 || (config.enabled && !config.definition?.config_id));
|
||||
const visibleAgent = selected ?? selectedAgentRef.current;
|
||||
if (visibleAgent) setDefinition(visibleAgent.definition);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -163,11 +175,14 @@ export function TeacherChatPanel({
|
||||
const listed = await teacherApi.list(base);
|
||||
if (!alive) return;
|
||||
setTopics(listed);
|
||||
if (externalTopicRef.current) {
|
||||
const previous = listed.items.find((item) => item.id === listed.lastSelectedTopicId
|
||||
&& (!selectedAgentId || item.teacherId === selectedAgentId))
|
||||
?? (selectedAgentId ? listed.items.find((item) => item.teacherId === selectedAgentId) : undefined);
|
||||
if (externalTopicRef.current && (!selectedAgentId || externalTopicRef.current.definition.config_id === selectedAgentId)) {
|
||||
consume(externalTopicRef.current);
|
||||
setDefinition(externalTopicRef.current.definition);
|
||||
} else if (listed.lastSelectedTopicId) {
|
||||
const current = await teacherApi.read(base, listed.lastSelectedTopicId);
|
||||
} else if (previous) {
|
||||
const current = await teacherApi.read(base, previous.id);
|
||||
if (alive && (!draftRevision || current.draftRevision === draftRevision)) {
|
||||
consume(current);
|
||||
setDefinition(current.definition);
|
||||
@@ -185,7 +200,7 @@ export function TeacherChatPanel({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
generation.current++;
|
||||
};
|
||||
}, [base, draftRevision, label, consume]);
|
||||
}, [base, draftRevision, label, consume, selectedAgentId]);
|
||||
useEffect(() => {
|
||||
if (!topic?.id || legacyFriend) return;
|
||||
const streamTopicId = topic.id;
|
||||
@@ -375,18 +390,6 @@ export function TeacherChatPanel({
|
||||
{onExpand && <button className="consult-icon" aria-label={expanded ? '恢复咨询栏宽度' : '展开咨询栏'} onClick={onExpand}><Maximize2 className="h-4 w-4" /></button>}
|
||||
{onClose && <button className="consult-icon" aria-label={`关闭${label}`} onClick={onClose}><X className="h-4 w-4" /></button>}
|
||||
</header>
|
||||
{!draftRevision && teachers.length > 0 && (
|
||||
<label className="px-5 pb-2 text-[11px] text-muted-foreground">
|
||||
新话题使用的智能体
|
||||
<select aria-label="新话题使用的智能体" value={selectedTeacherVersion ?? ''} disabled={busy}
|
||||
onChange={(event) => { const selected = teachers.find(item => item.version === Number(event.target.value)); setSelectedTeacherVersion(selected?.version); if (!topic && selected) setDefinition(selected.definition); }}
|
||||
className="mt-1 w-full rounded-lg border bg-background p-2 text-xs text-foreground">
|
||||
{teachers.map((item) => <option key={item.teacher_id} value={item.version}>
|
||||
{item.definition.name}{item.is_default ? '(默认)' : ''}
|
||||
</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{(topics.items.length > 0 || draftRevision) && <div className="px-5 pt-2">
|
||||
<button className="text-[11px] text-muted-foreground" onClick={() => setHistoryOpen(!historyOpen)} aria-expanded={historyOpen}>以往讨论 <ChevronDown className="inline h-3 w-3" /></button>
|
||||
{(historyOpen || draftRevision) && <div className="mt-2 flex gap-2">
|
||||
@@ -458,6 +461,13 @@ export function TeacherChatPanel({
|
||||
{topic?.unsaved && <div className="px-5 py-2 text-xs text-destructive">回复尚未保存,请复制或重试保存。<button className="ml-2 underline" onClick={() => void teacherApi.save(topicBase, topic.id).then(consume).catch(() => setError('保存失败,请先复制回复。'))}>重试保存</button></div>}
|
||||
{!topicEnabled && !error && !busy && <p className="px-5 pb-2 text-xs text-muted-foreground">{legacyFriend ? '这是旧版内置朋友的话题,仅供查看。请选择智能体新建话题。' : `${label}暂未开放,历史仍可查看。`}</p>}
|
||||
<form className="px-4 pb-4 pt-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
|
||||
{Boolean(unassignedDraft.text?.trim() || unassignedDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
|
||||
<summary>查看之前的项目草稿</summary>
|
||||
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">
|
||||
{unassignedDraft.text}
|
||||
{unassignedDraft.references?.map((ref, index) => <pre className="mt-2 whitespace-pre-wrap" key={index}>{ref.text}</pre>)}
|
||||
</div>
|
||||
</details>}
|
||||
{Boolean(legacyDraft.text?.trim() || legacyDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
|
||||
<summary>查看旧版朋友草稿</summary>
|
||||
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">
|
||||
|
||||
@@ -7,23 +7,38 @@ type Companion = ReturnType<typeof useTeacherCompanion>;
|
||||
export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
|
||||
companion: Companion;
|
||||
open: boolean;
|
||||
onOpen(): void;
|
||||
onOpen(agentId?: string): void;
|
||||
bubbleRef?: Ref<HTMLDivElement>;
|
||||
}) {
|
||||
const { definition, invitation, welcome, loading, enabled, checking, error } = companion;
|
||||
const name = definition?.name || '智能体';
|
||||
const avatar = getAgentAvatarSrc(definition?.avatar_id);
|
||||
const activeId = companion.selectedAgent?.teacher_id;
|
||||
const agents = companion.selectedAgent && !companion.agents.some((item) => item.teacher_id === activeId)
|
||||
? [...companion.agents, companion.selectedAgent] : companion.agents;
|
||||
const viewConversation = () => { companion.dismiss(); onOpen(); };
|
||||
const status = loading ? '正在连接智能体…'
|
||||
: checking ? '智能体在看看你最近的进展…'
|
||||
: error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说');
|
||||
return (
|
||||
<section className="teacher-companion no-drag" aria-label="智能体陪伴" data-testid="teacher-companion">
|
||||
<button type="button" className="teacher-companion-person" title={status} aria-label="智能体" aria-expanded={open} aria-controls="coding-consultation-dock" onMouseDown={(event) => event.preventDefault()} onClick={onOpen}>
|
||||
<nav className="consultation-agent-tabs" aria-label="已下发智能体">
|
||||
{agents.map((agent) => <button key={agent.teacher_id} type="button" className="teacher-companion-person"
|
||||
title={agent.definition.description || agent.definition.name} aria-label={agents.length === 1 ? '智能体' : `与${agent.definition.name}聊天`}
|
||||
aria-expanded={open && activeId === agent.teacher_id} aria-pressed={open && activeId === agent.teacher_id}
|
||||
aria-controls="coding-consultation-dock" data-agent-id={agent.teacher_id}
|
||||
onMouseDown={(event) => event.preventDefault()} onClick={() => onOpen(agent.teacher_id)}>
|
||||
<span className="teacher-companion-avatar"><img src={getAgentAvatarSrc(agent.definition.avatar_id)} alt="" />
|
||||
{activeId === agent.teacher_id && invitation && <span className="teacher-companion-unread" aria-hidden="true" />}</span>
|
||||
<span className="teacher-companion-name">{agent.definition.name}</span>
|
||||
{activeId === agent.teacher_id && checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
|
||||
</button>)}
|
||||
{(!agents.length || (!activeId && companion.topic)) && <button type="button" className="teacher-companion-person" title={status} aria-label="智能体" aria-expanded={open} aria-controls="coding-consultation-dock" onMouseDown={(event) => event.preventDefault()} onClick={() => onOpen()}>
|
||||
<span className="teacher-companion-avatar"><img src={avatar} alt="" />{invitation && <span className="teacher-companion-unread" aria-hidden="true" />}</span>
|
||||
<span className="teacher-companion-name">{name}</span>
|
||||
{checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
|
||||
</button>
|
||||
</button>}
|
||||
</nav>
|
||||
{(invitation || welcome) && <div ref={bubbleRef} className="teacher-companion-presence" data-testid="teacher-presence">
|
||||
<div className="teacher-companion-bubble" data-testid="teacher-invitation" data-bubble-kind={invitation ? 'check-in' : 'welcome'}>
|
||||
<div className="teacher-companion-bubble-heading">
|
||||
|
||||
@@ -69,7 +69,9 @@
|
||||
|
||||
/* The teacher speaks from the title bar without taking the student's focus. */
|
||||
.classroom-composer-view[hidden] { display: none; }
|
||||
.teacher-companion { position: relative; flex-shrink: 0; }
|
||||
.teacher-companion { position: relative; min-width: 0; max-width: 65%; }
|
||||
.consultation-agent-tabs { display: flex; align-items: center; gap: 4px; max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
|
||||
.consultation-agent-tabs .teacher-companion-person { flex-shrink: 0; }
|
||||
.teacher-companion-person { display: flex; align-items: center; gap: 6px; height: 36px; padding: 0 8px; border-radius: 8px; color: #747d6c; transition: background 150ms; }
|
||||
.teacher-companion-person:hover, .teacher-companion-person[aria-expanded="true"] { background: #eef1e8; color: #42543a; }
|
||||
.teacher-companion-avatar { position: relative; flex-shrink: 0; }
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
TEACHER_CHECK_IN_INTERVAL_MS,
|
||||
type TeacherCheckInInput,
|
||||
type TeacherDefinition,
|
||||
type TeacherCatalog,
|
||||
type TeacherTopic,
|
||||
} from '../../../shared/coding-teacher';
|
||||
|
||||
interface CompanionState {
|
||||
agents: TeacherCatalog['items'];
|
||||
selectedAgent?: TeacherCatalog['items'][number];
|
||||
definition: TeacherDefinition | null;
|
||||
enabled: boolean;
|
||||
loading: boolean;
|
||||
@@ -44,7 +47,8 @@ export function useTeacherCompanion(options: Options) {
|
||||
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
|
||||
const scope = `${account}:${options.projectId ?? ''}`;
|
||||
const storageKey = `makelore-teacher-presence:${scope}`;
|
||||
const [state, setState] = useState<CompanionState>({ definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: false, error: '' });
|
||||
const [state, setState] = useState<CompanionState>({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: false, error: '' });
|
||||
const selectionEpoch = useRef(0);
|
||||
const latest = useRef({ options, state });
|
||||
latest.current = { options, state };
|
||||
const scopeRef = useRef(scope);
|
||||
@@ -55,8 +59,18 @@ export function useTeacherCompanion(options: Options) {
|
||||
if (scopeRef.current !== scope || topic.projectId !== options.projectId) return;
|
||||
setState((current) => current.topic?.id === topic.id && current.topic.revision >= topic.revision
|
||||
? current
|
||||
: { ...current, topic, error: '' });
|
||||
: { ...current, topic, selectedAgent: current.agents.find((item) => item.teacher_id === topic.definition.config_id)
|
||||
?? (topic.definition.config_id ? { teacher_id: topic.definition.config_id, version: topic.version, definition: topic.definition, is_default: false } : undefined), error: '' });
|
||||
}, [scope, options.projectId]);
|
||||
const selectAgent = useCallback((id: string) => {
|
||||
const current = latest.current.state;
|
||||
if (id === (current.selectedAgent?.teacher_id ?? current.topic?.definition.config_id ?? current.definition?.config_id)) return;
|
||||
const agent = current.agents.find((item) => item.teacher_id === id);
|
||||
if (!agent) return;
|
||||
selectionEpoch.current++;
|
||||
setState((value) => ({ ...value, selectedAgent: agent, definition: agent.definition, topic: null,
|
||||
enabled: true, welcomeDismissed: true, error: '' }));
|
||||
}, []);
|
||||
const dismiss = useCallback(() => {
|
||||
const request = unreadInvitation(latest.current.state);
|
||||
writeLocal(storageKey + ':welcome-dismissed', true);
|
||||
@@ -77,7 +91,9 @@ export function useTeacherCompanion(options: Options) {
|
||||
const storedSeen = readLocal<unknown>(storageKey + ':seen', []);
|
||||
const seen = Array.isArray(storedSeen) ? storedSeen.filter((id): id is string => typeof id === 'string') : [];
|
||||
const welcomeDismissed = readLocal<boolean>(storageKey + ':welcome-dismissed', false) === true;
|
||||
setState({ definition: null, enabled: false, loading: true, topic: null, seen, welcomeDismissed, error: '' });
|
||||
selectionEpoch.current++;
|
||||
const initialSelection = selectionEpoch.current;
|
||||
setState({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen, welcomeDismissed, error: '' });
|
||||
const projectId = options.projectId;
|
||||
if (!projectId) {
|
||||
setState((current) => ({ ...current, loading: false }));
|
||||
@@ -86,8 +102,14 @@ export function useTeacherCompanion(options: Options) {
|
||||
const base = teacherTopicsPath(projectId);
|
||||
const loadConfig = async () => {
|
||||
const config = await teacherApi.config();
|
||||
if (alive) setState((current) => ({ ...current, definition: config.definition, enabled: config.enabled, loading: false }));
|
||||
return config;
|
||||
const catalog = await teacherApi.catalog();
|
||||
const selectedId = latest.current.state.selectedAgent?.teacher_id;
|
||||
const selected = catalog.items.find((item) => item.teacher_id === selectedId)
|
||||
?? (!selectedId ? catalog.items.find((item) => item.is_default) ?? catalog.items[0] : undefined);
|
||||
const enabled = selected ? true : !selectedId && config.enabled;
|
||||
if (alive) setState((current) => ({ ...current, agents: catalog.items,
|
||||
definition: selected?.definition ?? current.selectedAgent?.definition ?? current.topic?.definition ?? config.definition, enabled, loading: false }));
|
||||
return { enabled };
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -95,8 +117,8 @@ export function useTeacherCompanion(options: Options) {
|
||||
if (!alive) return;
|
||||
const topics = await teacherApi.list(base);
|
||||
if (!alive || !topics.lastSelectedTopicId) return;
|
||||
const topic = await teacherApi.read(base, topics.lastSelectedTopicId);
|
||||
if (alive && (!latest.current.state.topic || latest.current.state.topic.id === topic.id)) noteTopic(topic);
|
||||
const topic = await teacherApi.read(base, topics.lastSelectedTopicId, false);
|
||||
if (alive && initialSelection === selectionEpoch.current && (!latest.current.state.topic || latest.current.state.topic.id === topic.id)) noteTopic(topic);
|
||||
} catch {
|
||||
if (alive) setState((current) => ({ ...current, loading: false, error: '智能体暂时没连上,稍后再来看看。' }));
|
||||
}
|
||||
@@ -111,6 +133,8 @@ export function useTeacherCompanion(options: Options) {
|
||||
const { options: current, state: currentState } = latest.current;
|
||||
if (currentState.topic?.role === 'friend') return;
|
||||
if (!alive || inFlight.current || document.visibilityState !== 'visible' || currentState.loading) return;
|
||||
// A newly selected agent has no Main selection until its history loads or the student sends.
|
||||
if (currentState.selectedAgent && !currentState.topic) return;
|
||||
// A dropped event stream must not leave a completed reply stuck as thinking.
|
||||
if (currentState.topic?.requests.some((request) => ['preparing', 'running'].includes(request.status))) {
|
||||
inFlight.current = true;
|
||||
@@ -127,6 +151,7 @@ export function useTeacherCompanion(options: Options) {
|
||||
lastAttempt = Date.now();
|
||||
writeLocal(storageKey + ':last-attempt', lastAttempt);
|
||||
inFlight.current = true;
|
||||
const checkingSelection = selectionEpoch.current;
|
||||
try {
|
||||
const config = await loadConfig();
|
||||
const dispatch = latest.current.options;
|
||||
@@ -143,7 +168,7 @@ export function useTeacherCompanion(options: Options) {
|
||||
writeLocal(storageKey + ':last-attempt', lastAttempt);
|
||||
}
|
||||
writeLocal(storageKey + ':pending', null);
|
||||
if (result.topic) noteTopic(result.topic);
|
||||
if (result.topic && checkingSelection === selectionEpoch.current) noteTopic(result.topic);
|
||||
} catch (error) {
|
||||
if (!alive) return;
|
||||
if (error instanceof AppError && typeof error.details?.status === 'number' && error.details.status >= 400 && error.details.status < 500 && error.details.status !== 408) writeLocal(storageKey + ':pending', null);
|
||||
@@ -171,12 +196,13 @@ export function useTeacherCompanion(options: Options) {
|
||||
if (!topicId || !topicProjectId || topic?.role === 'friend') return;
|
||||
let alive = true;
|
||||
let stream: EventSource | undefined;
|
||||
const streamSelection = selectionEpoch.current;
|
||||
const base = teacherTopicsPath(topicProjectId);
|
||||
void teacherApi.events(base, topicId).then((source) => {
|
||||
if (!alive) { source.close(); return; }
|
||||
stream = source;
|
||||
source.addEventListener('snapshot', (event) => {
|
||||
if (!alive) return;
|
||||
if (!alive || streamSelection !== selectionEpoch.current) return;
|
||||
try { noteTopic(JSON.parse((event as MessageEvent).data) as TeacherTopic); } catch { /* Wait for the next authoritative snapshot. */ }
|
||||
});
|
||||
}).catch(() => { /* The consultation can still be opened and reloaded. */ });
|
||||
@@ -185,15 +211,20 @@ export function useTeacherCompanion(options: Options) {
|
||||
|
||||
const last = topic?.requests.at(-1);
|
||||
const invitation = unreadInvitation({ topic, seen: state.seen });
|
||||
const visibleState = stateScope.current === scope ? state : { definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: true, error: '' };
|
||||
const visibleState = stateScope.current === scope ? state : { agents: [], selectedAgent: undefined, definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: true, error: '' };
|
||||
// This is the published greeting, not a fabricated project assessment.
|
||||
const selectedDefinition = topic?.definition ?? visibleState.definition;
|
||||
const selectedAgentId = visibleState.selectedAgent?.teacher_id ?? selectedDefinition?.config_id;
|
||||
const selectedAgent = visibleState.agents.find((item) => item.teacher_id === selectedAgentId)
|
||||
?? visibleState.selectedAgent;
|
||||
const welcome = topic?.role !== 'friend' && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed
|
||||
? selectedDefinition?.welcome_message.trim() || null : null;
|
||||
return {
|
||||
...visibleState,
|
||||
error: last?.intent === 'check-in' && ['failed', 'interrupted'].includes(last.status) ? '智能体这次没连上,可以点头像找智能体聊聊。' : visibleState.error,
|
||||
definition: selectedDefinition,
|
||||
selectedAgent,
|
||||
selectAgent,
|
||||
topic,
|
||||
invitation,
|
||||
welcome,
|
||||
|
||||
@@ -13,7 +13,14 @@ export function Layout() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [width, setWidth] = useState(508);
|
||||
const [long, setLong] = useState(true);
|
||||
const count = Number(new URLSearchParams(location.search).get('agents') ?? 0);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const agents = Array.from({ length: count }, (_, index) => ({
|
||||
teacher_id: `agent-${index}`, version: index + 1, is_default: index === 0,
|
||||
definition: { config_id: `agent-${index}`, name: `智能体${index + 1}`, avatar_id: 'avatar-06' },
|
||||
}));
|
||||
const companion = {
|
||||
agents, selectedAgent: agents[selected],
|
||||
definition: { name: '编程老师', avatar_id: 'avatar-06' },
|
||||
invitation: visible ? { response: long ? '你刚才说想做一个宠物游戏。我们一起想想,什么样的小动作,会让你愿意一直陪它玩下去?可以先挑一个最想试的想法。' : '有什么编程问题想一起讨论?' } : null,
|
||||
welcome: null, enabled: true, loading: false, checking: false, error: null,
|
||||
@@ -21,9 +28,9 @@ export function Layout() {
|
||||
} as ComponentProps<typeof TeacherCompanion>['companion'];
|
||||
return <div className="classroom-workspace flex h-screen flex-col bg-white">
|
||||
<header className="relative z-50 flex h-10 shrink-0 items-center justify-end border-b px-3" data-testid="coding-conversation-header">
|
||||
<span className="mr-auto text-xs">布局测试项目 › hello</span>
|
||||
<TeacherCompanion companion={companion} open={open} onOpen={() => setOpen(!open)} bubbleRef={setBubble} />
|
||||
<span className="w-20 text-center text-xs">朋友</span>
|
||||
<span className="mr-auto min-w-0 truncate text-xs">布局测试项目 › hello</span>
|
||||
<TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(!open); }} bubbleRef={setBubble} />
|
||||
<span data-testid="window-controls" className="w-[132px] shrink-0 text-center text-xs">— □ ×</span>
|
||||
</header>
|
||||
<div className="relative flex min-h-0 flex-1" data-testid="coding-chat-panel" data-consultation-open={open}>
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
|
||||
@@ -539,7 +539,7 @@ async function installCodingFirstChatHost(
|
||||
const topicBase = `/${consultationPath}-topics`;
|
||||
const topicId = `${consultationPath}-topic`;
|
||||
const currentTopic = consultationTopics[consultationPath];
|
||||
if (path.endsWith(topicBase) && method === 'GET') return respond({items:currentTopic?[{id:currentTopic.id,title:'智能体话题'}]:[],lastSelectedTopicId:currentTopic?.id??null});
|
||||
if (path.endsWith(topicBase) && method === 'GET') return respond({items:currentTopic?[{id:currentTopic.id,title:'智能体话题',teacherId:(currentTopic.definition as {config_id?:string}).config_id}]:[],lastSelectedTopicId:currentTopic?.id??null});
|
||||
if (path.endsWith(topicBase) && method === 'POST') {
|
||||
const selected = teachers.find(item => item.version === body?.teacherVersion) ?? teachers[0];
|
||||
consultationTopics[consultationPath]={schemaVersion:1,revision:0,id:topicId,accountId:'e2e',projectId:project.id,sourceConversationId:conversation.id,definition:selected.definition,version:selected.version,createdAt:now,updatedAt:now,requests:[]};
|
||||
@@ -1778,14 +1778,16 @@ test('project consultations preserve student drafts and switch between work and
|
||||
await expect(composer).toBeHidden();
|
||||
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
|
||||
await expect(composer).toHaveValue('保留我的草稿');
|
||||
await page.getByRole('button',{name:'智能体',exact:true}).click();
|
||||
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
|
||||
const teacher=page.getByTestId('teacher-chat-panel');
|
||||
await expect(page.getByRole('tab',{name:'作品',exact:true})).toHaveAttribute('aria-selected','true');
|
||||
await expect(page.getByRole('tabpanel',{name:'作品',exact:true})).toBeVisible();
|
||||
await expect(composer).toBeHidden();
|
||||
await expect(composer).toHaveValue('保留我的草稿');
|
||||
await expect(teacher.getByText('一起理解代码')).toBeVisible();
|
||||
await teacher.getByRole('combobox', { name: '新话题使用的智能体' }).selectOption('9');
|
||||
await expect(teacher.getByRole('combobox', { name: '新话题使用的智能体' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '与代码智能体聊天' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '与朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.locator('#coding-consultation-dock')).toHaveCSS('width', '508px');
|
||||
await expect(teacher.getByTestId('consultation-composer-actions').getByRole('button', { name: '帮我看看', exact: true })).toBeVisible();
|
||||
await expect(teacher.getByText('把你的困惑说出来就好')).toHaveCount(0);
|
||||
@@ -1810,8 +1812,11 @@ test('project consultations preserve student drafts and switch between work and
|
||||
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
|
||||
await expect(composer).toBeVisible();
|
||||
await teacher.getByRole('button',{name:'关闭智能体'}).click();
|
||||
await expect(page.getByRole('button',{name:'朋友',exact:true})).toHaveCount(0);
|
||||
await page.getByRole('button',{name:'智能体',exact:true}).click();
|
||||
await page.getByRole('button', { name: '与代码智能体聊天' }).click();
|
||||
await expect(teacher.getByRole('heading', { name: '代码智能体' })).toBeVisible();
|
||||
await expect(teacher.getByRole('textbox', { name: '向智能体提问' })).toHaveValue('');
|
||||
await teacher.getByRole('textbox', { name: '向智能体提问' }).fill('另一个智能体的草稿');
|
||||
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
|
||||
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
|
||||
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
|
||||
const requests=(await readState(electronApp)).captured;
|
||||
@@ -1826,7 +1831,7 @@ test('project consultations preserve student drafts and switch between work and
|
||||
expect(consultationRequests[2].body).toMatchObject({intent:'guided-help',text:'我也说不清,你带我看看'});
|
||||
await page.screenshot({path:test.info().outputPath('project-teacher-side-chat.png')});
|
||||
await teacher.getByRole('button',{name:'关闭智能体'}).click();await expect(teacher).toHaveCount(0);
|
||||
await page.getByRole('button',{name:'智能体',exact:true}).click();
|
||||
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
|
||||
await expect(page.getByText('先理解状态如何随点击变化,再修改代码。',{exact:true})).toBeVisible();
|
||||
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
|
||||
} finally {await releaseSnapshot(electronApp);}
|
||||
|
||||
@@ -23,7 +23,7 @@ test.beforeAll(async () => {
|
||||
},
|
||||
configureServer(vite) {
|
||||
vite.middlewares.use((req, res, next) => {
|
||||
if (req.url !== '/') return next();
|
||||
if (req.url !== '/' && !req.url?.startsWith('/?')) return next();
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end('<html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/tests/e2e/fixtures/teacher-preview-layout.tsx"></script></body></html>');
|
||||
});
|
||||
@@ -35,6 +35,30 @@ test.beforeAll(async () => {
|
||||
});
|
||||
test.afterAll(async () => { await server?.close(); });
|
||||
|
||||
for (const width of [520, 1000]) {
|
||||
test(`distributed agent tabs scroll inside the title bar at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto(baseUrl + '?agents=8');
|
||||
const tabs = page.getByRole('navigation', { name: '已下发智能体' });
|
||||
await expect(tabs.getByRole('button')).toHaveCount(8);
|
||||
await expect(page.getByRole('combobox', { name: '新话题使用的智能体' })).toHaveCount(0);
|
||||
const last = tabs.getByRole('button', { name: '与智能体8聊天' });
|
||||
await last.focus();
|
||||
await last.press('Enter');
|
||||
await expect(last).toHaveAttribute('aria-pressed', 'true');
|
||||
const bounds = await page.evaluate(() => {
|
||||
const strip = document.querySelector('.consultation-agent-tabs')!;
|
||||
const controls = document.querySelector('[data-testid="window-controls"]')!;
|
||||
return { right: strip.getBoundingClientRect().right, controlsLeft: controls.getBoundingClientRect().left,
|
||||
scroll: strip.scrollLeft, documentWidth: document.documentElement.scrollWidth };
|
||||
});
|
||||
expect(bounds.right).toBeLessThanOrEqual(bounds.controlsLeft);
|
||||
expect(bounds.documentWidth).toBeLessThanOrEqual(width);
|
||||
expect(bounds.scroll).toBeGreaterThan(0);
|
||||
await page.screenshot({ path: test.info().outputPath('distributed-agents-titlebar.png') });
|
||||
});
|
||||
}
|
||||
|
||||
async function geometry(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const box = (selector: string) => document.querySelector(selector)!.getBoundingClientRect();
|
||||
|
||||
@@ -642,6 +642,22 @@ it('comparison focuses and directly sends the selected difference while preservi
|
||||
|
||||
|
||||
describe('merged cloud teacher classroom', () => {
|
||||
it('keeps an old unassigned project draft readable without copying it to the selected agent', async () => {
|
||||
const published = { ...definition, config_id: 'one' };
|
||||
const original = JSON.stringify({ text: '旧项目草稿', references: [{ kind: 'code', text: '旧引用' }] });
|
||||
const key = 'makelore-consultation-draft:signed-out:p:teacher';
|
||||
localStorage.setItem(key, original);
|
||||
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={{ teacher_id: 'one', version: 1, definition: published, is_default: true }} />);
|
||||
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
|
||||
fireEvent.click(screen.getByText('查看之前的项目草稿'));
|
||||
expect(screen.getByText('旧项目草稿')).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '新的独立草稿' } });
|
||||
expect(localStorage.getItem(key)).toBe(original);
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects a cloud teacher for the first project discussion without sending the operation draft', async () => {
|
||||
const cloudDefinition = { ...definition, runtime: 'yuxi' as const, config_id: 'algorithm', name: '算法智能体', yuxi: { agent_slug: 'algorithm', agent_version: 2 } };
|
||||
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
@@ -652,10 +668,10 @@ describe('merged cloud teacher classroom', () => {
|
||||
const selected = { ...topic('selected'), sourceConversationId: 'project', role: 'teacher' as const, definition: cloudDefinition, version: 9 };
|
||||
api.create.mockResolvedValue(selected);
|
||||
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({ ...selected, revision: 2, requests: [request({ id: input.requestId, text: input.text, response: '先看看玩家会做什么。' })] }));
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await waitFor(() => expect(screen.getByLabelText('新话题使用的智能体')).toBeEnabled());
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={{ teacher_id: 'algorithm', version: 9, definition: cloudDefinition, is_default: false }} />);
|
||||
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
|
||||
expect(screen.queryByLabelText('新话题使用的智能体')).not.toBeInTheDocument();
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText('新话题使用的智能体'), { target: { value: '9' } });
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我想做一个小游戏' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||
await screen.findByText('先看看玩家会做什么。');
|
||||
|
||||
@@ -1405,6 +1405,8 @@ describe('project consultations with selected cloud teachers', () => {
|
||||
const topic = await f.service.create(scope, undefined, undefined, runtime === 'yuxi' ? 9 : undefined);
|
||||
expect(topic.role).toBeUndefined();
|
||||
expect(topic.definition.teacher_id).toBe('coding-teacher');
|
||||
expect((await f.service.list(scope)).items.find((item) => item.id === topic.id)?.teacherId)
|
||||
.toBe(runtime === 'yuxi' ? 'cloud-teacher' : undefined);
|
||||
await f.service.send(scope, topic.id, { requestId: crypto.randomUUID(), text: '你觉得怎么样?', sourceConversationId: f.scope.sourceId });
|
||||
const messages = f.run.mock.calls[0][0];
|
||||
expect(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion';
|
||||
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
|
||||
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
|
||||
|
||||
const api = vi.hoisted(() => ({ config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn() }));
|
||||
const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn() }));
|
||||
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project }));
|
||||
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid' } }) }));
|
||||
const definition: TeacherDefinition = {
|
||||
@@ -20,8 +20,8 @@ function Harness({ projectId = 'project', sourceId = 'source', busy = false, arc
|
||||
const [open, setOpen] = useState(false);
|
||||
const [teacherComposing, setTeacherComposing] = useState(false);
|
||||
const companion = useTeacherCompanion({ projectId, sourceId, sourceBusy: busy, sourceArchived: archived, teacherOpen: open, teacherComposing });
|
||||
return <><TeacherCompanion companion={companion} open={open} onOpen={() => setOpen(true)} />
|
||||
{open && <TeacherChatPanel key={projectId} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} onClose={() => setOpen(false)} />}</>;
|
||||
return <><TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) companion.selectAgent(id); setOpen(true); }} />
|
||||
{open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} onClose={() => setOpen(false)} />}</>;
|
||||
}
|
||||
async function mount(props: Parameters<typeof Harness>[0] = {}) {
|
||||
let view!: ReturnType<typeof render>;
|
||||
@@ -35,6 +35,7 @@ beforeEach(() => {
|
||||
vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear();
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
api.config.mockResolvedValue({ enabled: true, definition });
|
||||
api.catalog.mockResolvedValue({ items: [] });
|
||||
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
api.events.mockImplementation(async () => Object.assign(new EventTarget(), { close: vi.fn() }));
|
||||
api.checkIn.mockImplementation(async (projectId: string) => ({ topic: checkInTopic(projectId) }));
|
||||
@@ -42,6 +43,76 @@ beforeEach(() => {
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
describe('teacher presence and periodic guidance', () => {
|
||||
it('shows every distributed agent in the header and preserves separate drafts without dispatching on switch', async () => {
|
||||
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
|
||||
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友', avatar_id: 'avatar-09' } };
|
||||
api.catalog.mockResolvedValue({ items: [a, b] });
|
||||
api.create.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] });
|
||||
api.send.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] });
|
||||
await mount();
|
||||
expect(screen.getByRole('button', { name: '与方法老师聊天' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
||||
expect(screen.queryByLabelText('新话题使用的智能体')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '体验的草稿' } });
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '方法的草稿' } });
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('体验的草稿');
|
||||
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
await advance();
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
||||
expect(api.create).toHaveBeenCalledWith('project', undefined, undefined, 9);
|
||||
expect(api.send.mock.calls[0][2].text).toBe('体验的草稿');
|
||||
});
|
||||
|
||||
it('restores the selected agent history at its pinned version instead of the globally last topic', async () => {
|
||||
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
|
||||
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
|
||||
const oldA = { ...checkInTopic(), id: 'a-topic', version: 1, definition: a.definition, requests: [] };
|
||||
const oldB = { ...checkInTopic(), id: 'b-topic', version: 8, definition: b.definition, requests: [] };
|
||||
api.catalog.mockResolvedValue({ items: [a, b] });
|
||||
api.list.mockResolvedValue({ items: [
|
||||
{ id: oldB.id, teacherId: b.teacher_id, title: '体验历史' },
|
||||
{ id: oldA.id, teacherId: a.teacher_id, title: '方法历史' },
|
||||
], lastSelectedTopicId: oldB.id });
|
||||
api.read.mockImplementation(async (_base, id) => id === oldA.id ? oldA : oldB);
|
||||
api.send.mockResolvedValue({ ...oldA, revision: 3 });
|
||||
await mount();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
|
||||
expect(api.read).toHaveBeenCalledWith('project', oldA.id);
|
||||
expect(screen.getByRole('heading', { name: '方法老师' })).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '继续原来的讨论' } });
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
||||
expect(api.send.mock.calls[0][1]).toBe(oldA.id);
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
||||
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
||||
expect(api.read).toHaveBeenCalledWith('project', oldB.id);
|
||||
});
|
||||
|
||||
it('ignores a late initial history read after the student selects a different agent', async () => {
|
||||
const a = { teacher_id: 'a', version: 1, is_default: true, definition: { ...definition, config_id: 'a', name: '方法老师' } };
|
||||
const b = { teacher_id: 'b', version: 2, is_default: false, definition: { ...definition, config_id: 'b', name: '体验朋友' } };
|
||||
const old = { ...checkInTopic(), definition: a.definition };
|
||||
api.catalog.mockResolvedValue({ items: [a, b] });
|
||||
api.list.mockResolvedValue({ items: [{ id: old.id, teacherId: 'a' }], lastSelectedTopicId: old.id });
|
||||
let resolve!: (topic: TeacherTopic) => void;
|
||||
api.read.mockReturnValue(new Promise<TeacherTopic>((done) => { resolve = done; }));
|
||||
await mount();
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
||||
await act(async () => resolve(old));
|
||||
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(api.read).toHaveBeenCalledWith('project', old.id, false);
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the selected agent greeting instead of borrowing the default agent greeting', async () => {
|
||||
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '默认智能体欢迎语' } });
|
||||
const selected = { ...checkInTopic(), requests: [], definition: { ...definition, name: '体验朋友', avatar_id: 'avatar-09', welcome_message: '我们聊聊你的体验' } };
|
||||
|
||||
Reference in New Issue
Block a user