feat(chat): add manual refresh for delivered agents

This commit is contained in:
鲨鱼辣椒 committed 2026-09-28 13:06:38 +08:00
1 parent 862477f9d4
commit 2b1943dd0f
13 files changed
+517 -59

No files matched your search

@@ -0,0 +1,56 @@
# Task: Inspect delivered agent refresh behavior
## Identity
- Task ID: 20260928-agent-refresh-a6d109b3
- Mode: Feature
- Branch: codex/20260928-agent-refresh-a6d109b3-agent-refresh
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-agent-refresh-a6d109b3
- Base commit: b26e25c9ed26bf30f4d0bc03869c223f0fe34386
- Owner: codex
- Status: Ready for Integration
## Scope
- Diagnose delayed delivered-Agent updates, then implement the user-approved manual refresh button beside the topbar Agent list.
- Share refreshed catalog/configuration with the open consultation panel without replacing its topic, draft or published version.
## Intent And Constraints
- User first requested diagnosis and product-language evaluation with no code changes, then explicitly requested implementation.
- Refresh is read-only and independent of proactive check-in eligibility; no model question, paid call, current-topic upgrade, automatic Agent switch or draft clearing.
- Preserve the latest approved hover introductions and simplified consultation panel by fast-forwarding this owned checkout to committed dependency 862477f. Its source task record remains byte-for-byte unchanged. Do not alter peer checkouts or the running trial.
## Project Context Loaded
- Same task/worktree resumed with matching start/status ownership; feature mode and recorded branch/base verified. Prior positioning, integrated state, decision index, teacher ADR, architecture, domain and evidence context remain loaded. Updated hover-intro peer task record reviewed; all eight peer scopes assessed, no conflicting product decisions. Concurrent and Planning Gates Passed.
- Current rules: Main owns cloud access; published Agent identity/configuration comes from the catalog; old topics pin their version. Initial catalog reads, panel-open reads and existing automatic check-ins remain supported.
- Adapt the design skill's compact, accessible loading/error feedback to the existing light UI; no new fonts, theme, or navigation patterns.
- Plan: add shared read-only loader and manual action, preserve drafts/selection/pinned history, verify unit/typecheck/build/layout/Electron behavior and obtain independent review.
## Outcome
- Added a compact Refresh control outside the scrolling Agent strip, with disabled/spinning loading state, three-second success feedback, visible failure feedback and retry. Keyboard labeling and polite status announcements are included.
- Manual refresh immediately reads configuration and catalog through the existing Main API, bypassing proactive five-minute/call-eligibility gates. Concurrent callers share the same request; failed reads retain the prior catalog; obsolete scope/unmounted responses are ignored.
- Topbar and open panel consume the same catalog. Same-ID updates change the version used by new topics without remounting the panel or changing existing topic definitions/versions. Opening or switching a panel still checks through the shared loader.
- Preserved current Agent selection, operation and consultation drafts, pinned history, unread messages, and pending sends. First delivery while an unassigned editor is open keeps that editor/draft stable until the user explicitly selects an Agent or a topic is bound.
- No Main/backend change, production installation, main merge, remote push, live model call, or running-trial restart.
## Verification
- Passed: 67 tests across teacher-companion and coding-teacher-ui, including seven new cases covering busy/composing refresh, duplicate clicks, shared latest versions, old-topic/history/draft/unread preservation, independent config/catalog errors and retry, first delivery, and obsolete project responses.
- Passed: 38 tests across coding-chat-panel and preview-clearance on final product code; 105 relevant unit tests in total.
- Passed: standard typecheck, changed-file ESLint, git diff whitespace check, production Renderer/Main/Preload/utility compilation using pinned pnpm 10.33.4.
- Passed: all 9 existing/extended Chrome layout scenarios, including 520px and 1000px scrolling strips with an always-reachable refresh control. Desktop and narrow screenshots visually reviewed.
- Passed: final-build Electron manual-refresh and proactive-presence scenarios. Manual refresh verifies held/loading state, new/renamed Agent, unchanged old topic/reply, operation and consultation draft preservation, failed refresh retention and retry. Captured API requests prove refresh performs no message/check-in/prompt POST or topic reload.
- Independent review found first-delivery draft remounting; fixed with an explicit unassigned selection and covered by a focused regression. Existing consultation E2E exposed delayed introduction reopening after clicking an Agent; explicit click suppression now preserves closure until genuine outside reentry or keyboard focus. Re-review found no additional actionable issue for either fix.
- Passed on final build: all 3 focused Electron cases (consultation switching/drafts/hover, manual refresh, proactive presence) plus 4 affected scrolling/hover layout cases, 7/7 in 16.9 seconds. No assertion was weakened. A prior unrelated sidebar drag assertion (193px instead of 256px) matched the source task’s documented native-input flake; the final full consultation scenario passed. The proactive fixture was corrected to create its new topic from the actual distributed default definition/version. Final screenshots are under ignored test-results/agent-refresh-verified/.
- check_project_docs passed. Task-aware drift checker reports the unchanged source task record inherited from 862477f as FOREIGN_DOC because it compares all commits to the original b26e25c base. Direct comparison to dependency 862477f is empty; Git blob and working-file hash both equal ffd72a12407ef3d963f49e42afa29549300f9883. No foreign/canonical document was edited, deleted, or adopted; record this checker limitation without weakening its rules.
## Follow-ups
- Current running trial still uses the hover-intro checkout; this implementation is built and verified in the isolated task checkout. Integrate/update that client separately, preserving any active unsent input.
## Promotion Candidates
- Target: canonical classroom interaction/current-state documentation. Proposal: topbar manual refresh immediately synchronizes catalog/configuration with open consultation, preserves drafts/history and old-topic version pinning, and issues only read requests. Evidence: explicit user request, source change, 105 unit tests, layout and Electron checks, independent review. Future impact: operational Agent publication can be checked immediately without waiting for proactive cadence. Semantic conflicts: none. Human confirmation: already supplied for this feature; canonical promotion remains integration-only.
+2
View File
@@ -160,6 +160,8 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
### 下发智能体咨询 ### 下发智能体咨询
顶部智能体名单旁的“刷新”按钮可立即获取最新下发名单、发布资料和新话题版本,独立于主动关心的检查条件。刷新中显示进度,成功提示“已更新”,失败保留原名单并可重试;顶部和已打开的咨询面板使用同一份结果。刷新不发起模型请求、不切换当前智能体、不清除草稿或历史,已有话题继续使用创建时绑定的版本。
老师 Agent 是某一领域杰出人物的能力转化而成的智能体,供平台学生使用,在真实项目、作品和问题中提供专业理解、判断与解决问题的能力支持。定义以人的领域能力及学生价值为中心;蒸馏属于实现方法,云端下发、上下文读取和聊天是能力交付方式。完整理念见[老师 Agent 产品定位](.project-docs/00-brief/project-positioning.md#老师-agent-的产品定位)。 老师 Agent 是某一领域杰出人物的能力转化而成的智能体,供平台学生使用,在真实项目、作品和问题中提供专业理解、判断与解决问题的能力支持。定义以人的领域能力及学生价值为中心;蒸馏属于实现方法,云端下发、上下文读取和聊天是能力交付方式。完整理念见[老师 Agent 产品定位](.project-docs/00-brief/project-positioning.md#老师-agent-的产品定位)。
- 新话题可选择运营下发的 Yuxi 智能体,默认使用指定智能体;提示词、Skills、知识库和模型配置由 Yuxi 管理,运营同步后生成新版本,已有话题固定原版本。Yuxi 智能体模型费用由当前学生的编程词元点数支付,个人 Agents 模块仍由创建者付费;不要求学生开通个人云智能体模块。停用的智能体仍可查看历史,不能借另一位智能体的可用状态继续提问。 - 新话题可选择运营下发的 Yuxi 智能体,默认使用指定智能体;提示词、Skills、知识库和模型配置由 Yuxi 管理,运营同步后生成新版本,已有话题固定原版本。Yuxi 智能体模型费用由当前学生的编程词元点数支付,个人 Agents 模块仍由创建者付费;不要求学生开通个人云智能体模块。停用的智能体仍可查看历史,不能借另一位智能体的可用状态继续提问。
+4
View File
@@ -10,6 +10,10 @@ import type {
TeacherTopic, TeacherTopic,
TeacherTopicList, TeacherTopicList,
} from '../../shared/coding-teacher'; } from '../../shared/coding-teacher';
export interface TeacherCatalogState {
config: TeacherAvailability & { definition: TeacherDefinition | null };
catalog: TeacherCatalog;
}
export function teacherTopicsPath(projectId: string) { export function teacherTopicsPath(projectId: string) {
return projectId === 'preview' return projectId === 'preview'
? '/api/coding/teacher-preview/topics' ? '/api/coding/teacher-preview/topics'
+2
View File
@@ -972,6 +972,8 @@ export function CodingChatPanel({
<TeacherChatPanel key={`${account}:${activeProject.id}:${teacherCompanion.selectedAgent?.teacher_id ?? 'history'}`} <TeacherChatPanel key={`${account}:${activeProject.id}:${teacherCompanion.selectedAgent?.teacher_id ?? 'history'}`}
projectId={activeProject.id} projectName={activeProject.name} sourceId={targetConversationId ?? ''} projectId={activeProject.id} projectName={activeProject.name} sourceId={targetConversationId ?? ''}
selectedAgent={teacherCompanion.selectedAgent} selectedAgent={teacherCompanion.selectedAgent}
catalogState={teacherCompanion.catalogState}
reloadCatalog={teacherCompanion.reloadCatalog}
externalTopic={teacherCompanion.topic} externalTopic={teacherCompanion.topic}
onTopicChange={teacherCompanion.noteTopic} onTopicChange={teacherCompanion.noteTopic}
onComposingChange={setTeacherComposing} onComposingChange={setTeacherComposing}
+47 -25
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowUp, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; import { ArrowUp, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { teacherApi, teacherTopicsPath, legacyTopicBase } from '@/lib/coding-teacher'; import { teacherApi, teacherTopicsPath, legacyTopicBase, type TeacherCatalogState } from '@/lib/coding-teacher';
import type { import type {
TeacherDefinition, TeacherDefinition,
TeacherCatalog, TeacherCatalog,
@@ -26,6 +26,8 @@ export function TeacherChatPanel({
onTopicChange, onTopicChange,
onComposingChange, onComposingChange,
selectedAgent, selectedAgent,
catalogState,
reloadCatalog,
}: { }: {
projectId: string; projectId: string;
sourceId: string; sourceId: string;
@@ -38,6 +40,8 @@ export function TeacherChatPanel({
onTopicChange?(topic: TeacherTopic): void; onTopicChange?(topic: TeacherTopic): void;
onComposingChange?(composing: boolean): void; onComposingChange?(composing: boolean): void;
selectedAgent?: TeacherCatalog['items'][number]; selectedAgent?: TeacherCatalog['items'][number];
catalogState?: TeacherCatalogState;
reloadCatalog?(): Promise<void>;
}) { }) {
const base = teacherTopicsPath(projectId); const base = teacherTopicsPath(projectId);
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out'); const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
@@ -60,7 +64,9 @@ export function TeacherChatPanel({
const discussionEnabled = !draftRevision; const discussionEnabled = !draftRevision;
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
const [focus, setFocus] = useState<{ topicId: string; id: string; label: string } | null>(null); const [focus, setFocus] = useState<{ topicId: string; id: string; label: string } | null>(null);
const [definition, setDefinition] = useState<TeacherDefinition | null>(null); const [localDefinition, setDefinition] = useState<TeacherDefinition | null>(null);
const catalogStateRef = useRef(catalogState);
catalogStateRef.current = catalogState;
const externalTopicRef = useRef(externalTopic); const externalTopicRef = useRef(externalTopic);
externalTopicRef.current = externalTopic; externalTopicRef.current = externalTopic;
const selectedAgentId = selectedAgent?.teacher_id; const selectedAgentId = selectedAgent?.teacher_id;
@@ -80,10 +86,20 @@ export function TeacherChatPanel({
draftRef.current = { text, references }; draftRef.current = { text, references };
const [error, setError] = useState(''); const [error, setError] = useState('');
const [busy, setBusy] = useState(true); const [busy, setBusy] = useState(true);
const [enabled, setEnabled] = useState(false); const [localEnabled, setEnabled] = useState(false);
const [legacyEnabled, setLegacyEnabled] = useState(false); const [localLegacyEnabled, setLegacyEnabled] = useState(false);
const [teachers, setTeachers] = useState<TeacherCatalog['items']>([]); const [localTeachers, setTeachers] = useState<TeacherCatalog['items']>([]);
const [selectedTeacherVersion, setSelectedTeacherVersion] = useState<number>(); const [localTeacherVersion, setSelectedTeacherVersion] = useState<number>();
// Share the current catalog with the topbar without reloading the topic or its draft.
const teachers = catalogState?.catalog.items ?? localTeachers;
const selectedTeacher = selectedAgentId ? teachers.find((item) => item.teacher_id === selectedAgentId)
: teachers.find((item) => item.is_default) ?? teachers[0];
const enabled = catalogState ? (selectedAgentId ? Boolean(selectedTeacher)
: teachers.length > 0 || (catalogState.config.enabled && !catalogState.config.definition?.config_id)) : localEnabled;
const legacyEnabled = catalogState?.config.enabled ?? localLegacyEnabled;
const selectedTeacherVersion = catalogState ? selectedTeacher?.version : localTeacherVersion;
const definition = topic?.definition ?? (catalogState
? selectedTeacher?.definition ?? selectedAgent?.definition ?? catalogState.config.definition : localDefinition);
const topicEnabled = legacyFriend ? false : !topic ? enabled const topicEnabled = legacyFriend ? false : !topic ? enabled
: topic.definition.config_id : topic.definition.config_id
? teachers.some((item) => item.teacher_id === topic.definition.config_id) ? teachers.some((item) => item.teacher_id === topic.definition.config_id)
@@ -140,25 +156,31 @@ export function TeacherChatPanel({
setBusy(true); setBusy(true);
void (async () => { void (async () => {
try { try {
const config = draftRevision if (!draftRevision && reloadCatalog) {
? { definition: (await teacherApi.preview(draftRevision)).payload, enabled: true } // Opening the panel still checks for updates through the shared loader.
: await teacherApi.config(); // A failed background read leaves the last usable catalog in place.
if (alive) { await reloadCatalog().catch(() => undefined);
setDefinition(config.definition); } else if (draftRevision || !catalogStateRef.current) {
setEnabled(config.enabled); const config = draftRevision
setLegacyEnabled(config.enabled); ? { definition: (await teacherApi.preview(draftRevision)).payload, enabled: true }
} : await teacherApi.config();
if (!draftRevision) {
const catalog = await teacherApi.catalog();
if (alive) { if (alive) {
setTeachers(catalog.items); setDefinition(config.definition);
const selected = selectedAgentId setEnabled(config.enabled);
? catalog.items.find((item) => item.teacher_id === selectedAgentId) setLegacyEnabled(config.enabled);
: catalog.items.find((item) => item.is_default) ?? catalog.items[0]; }
setSelectedTeacherVersion(selected?.version); if (!draftRevision) {
setEnabled(selectedAgentId ? Boolean(selected) : catalog.items.length > 0 || (config.enabled && !config.definition?.config_id)); const catalog = await teacherApi.catalog();
const visibleAgent = selected ?? selectedAgentRef.current; if (alive) {
if (visibleAgent) setDefinition(visibleAgent.definition); setTeachers(catalog.items);
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(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) { } catch (e) {
@@ -193,7 +215,7 @@ export function TeacherChatPanel({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
generation.current++; generation.current++;
}; };
}, [base, draftRevision, label, consume, selectedAgentId]); }, [base, draftRevision, label, consume, selectedAgentId, reloadCatalog]);
useEffect(() => { useEffect(() => {
if (!topic?.id || legacyFriend) return; if (!topic?.id || legacyFriend) return;
const streamTopicId = topic.id; const streamTopicId = topic.id;
+11 -1
View File
@@ -1,4 +1,4 @@
import { ArrowUpRight, Loader2, X } from 'lucide-react'; import { ArrowUpRight, Loader2, RefreshCw, X } from 'lucide-react';
import { useState, type Ref } from 'react'; import { useState, type Ref } from 'react';
import { Provider as TooltipProvider } from '@radix-ui/react-tooltip'; import { Provider as TooltipProvider } from '@radix-ui/react-tooltip';
import { TeacherAvatar } from './TeacherAvatar'; import { TeacherAvatar } from './TeacherAvatar';
@@ -28,6 +28,9 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef,
const status = loading ? '正在连接智能体…' const status = loading ? '正在连接智能体…'
: checking ? '智能体在看看你最近的进展…' : checking ? '智能体在看看你最近的进展…'
: error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说'); : error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说');
const refreshMessage = companion.refreshing ? '正在刷新…'
: companion.refreshStatus === 'error' ? '刷新失败,请重试'
: companion.refreshStatus === 'success' ? '已更新' : '';
return ( return (
<TooltipProvider delayDuration={180} skipDelayDuration={100}> <TooltipProvider delayDuration={180} skipDelayDuration={100}>
<section className="teacher-companion no-drag" aria-label="智能体陪伴" data-testid="teacher-companion"> <section className="teacher-companion no-drag" aria-label="智能体陪伴" data-testid="teacher-companion">
@@ -50,6 +53,13 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef,
{checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />} {checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
</button></TeacherIntroduction>} </button></TeacherIntroduction>}
</nav> </nav>
<button type="button" className="consultation-agents-refresh" aria-label="刷新智能体"
title={refreshMessage || '刷新智能体'} disabled={loading || companion.refreshing} aria-busy={companion.refreshing}
onMouseDown={(event) => event.preventDefault()} onClick={() => { setIntroductionId(null); void companion.refresh(); }}>
<RefreshCw className={`h-3.5 w-3.5 shrink-0 ${companion.refreshing ? 'animate-spin motion-reduce:animate-none' : ''}`} aria-hidden="true" />
<span>{companion.refreshing ? '刷新中' : companion.refreshStatus === 'error' ? '刷新失败' : companion.refreshStatus === 'success' ? '已更新' : '刷新'}</span>
</button>
<span className="sr-only" role="status" aria-live="polite">{refreshMessage}</span>
{(invitation || welcome) && <div ref={bubbleRef} className="teacher-companion-presence" data-testid="teacher-presence"> {(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" data-testid="teacher-invitation" data-bubble-kind={invitation ? 'check-in' : 'welcome'}>
<div className="teacher-companion-bubble-heading"> <div className="teacher-companion-bubble-heading">
+15 -3
View File
@@ -1,6 +1,6 @@
import * as Tooltip from '@radix-ui/react-tooltip'; import * as Tooltip from '@radix-ui/react-tooltip';
import { Sparkles } from 'lucide-react'; import { Sparkles } from 'lucide-react';
import type { ReactElement, Ref } from 'react'; import { useRef, type ReactElement, type Ref } from 'react';
import type { TeacherDefinition } from '../../../shared/coding-teacher'; import type { TeacherDefinition } from '../../../shared/coding-teacher';
import { TeacherAvatar } from './TeacherAvatar'; import { TeacherAvatar } from './TeacherAvatar';
@@ -13,11 +13,23 @@ export function TeacherIntroduction({ definition, children, contentRef, open, on
onOpenChange(open: boolean): void; onOpenChange(open: boolean): void;
}) { }) {
const description = definition?.description?.trim(); const description = definition?.description?.trim();
return <Tooltip.Root open={Boolean(description) && open} onOpenChange={onOpenChange}> const clicked = useRef(false);
return <Tooltip.Root open={Boolean(description) && open} onOpenChange={(next) => {
if (!next || !clicked.current) onOpenChange(next);
}}>
<Tooltip.Trigger asChild onPointerEnter={(event) => { <Tooltip.Trigger asChild onPointerEnter={(event) => {
// Adjacent entries must switch immediately, even inside the previous // Adjacent entries must switch immediately, even inside the previous
// tooltip's pointer grace area used when moving into its readable card. // tooltip's pointer grace area used when moving into its readable card.
if (event.pointerType !== 'touch') onOpenChange(true); if (event.pointerType === 'touch') return;
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
clicked.current = false;
onOpenChange(true);
}} onClick={() => {
// A delayed Radix hover callback must not reopen the card after selection.
clicked.current = true;
onOpenChange(false);
}} onFocus={() => {
clicked.current = false;
}} onPointerMove={(event) => { }} onPointerMove={(event) => {
// pointerEnter owns hover opening. Skip Radix's second opening path so // pointerEnter owns hover opening. Skip Radix's second opening path so
// moving inside an entry after clicking it cannot reopen its introduction. // moving inside an entry after clicking it cannot reopen its introduction.
+5 -2
View File
@@ -73,7 +73,7 @@
/* The teacher speaks from the title bar without taking the student's focus. */ /* The teacher speaks from the title bar without taking the student's focus. */
.classroom-composer-view[hidden] { display: none; } .classroom-composer-view[hidden] { display: none; }
.teacher-companion { position: relative; min-width: 0; max-width: 65%; } .teacher-companion { position: relative; display: flex; align-items: center; gap: 4px; 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 { 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-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-avatar { width: 48px; height: 48px; flex-shrink: 0; border: 2px solid #fff; border-radius: 15px; background: #e8f1fb; box-shadow: 0 3px 0 #d6e3ee; }
@@ -85,7 +85,10 @@
.teacher-introduction-arrow { fill: #ead6af; } .teacher-introduction-arrow { fill: #ead6af; }
@keyframes teacher-introduction-in { from { opacity: 0; } to { opacity: 1; } } @keyframes teacher-introduction-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .teacher-introduction { animation: none; } } @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 { display: flex; align-items: center; gap: 4px; min-width: 0; max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
.consultation-agents-refresh { display: flex; align-items: center; justify-content: center; gap: 5px; flex-shrink: 0; width: 76px; height: 32px; border: 1px solid #dce4d5; border-radius: 8px; color: #526348; background: #f7f9f3; font-size: 12px; white-space: nowrap; cursor: pointer; transition: background 150ms; }
.consultation-agents-refresh:hover:not(:disabled) { background: #eaf0e1; }
.consultation-agents-refresh:disabled { cursor: default; opacity: .6; }
.consultation-agent-tabs .teacher-companion-person { flex-shrink: 0; } .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 { 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-person:hover, .teacher-companion-person[aria-expanded="true"] { background: #eef1e8; color: #42543a; }
+65 -18
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { teacherApi, teacherTopicsPath } from '@/lib/coding-teacher'; import { teacherApi, teacherTopicsPath, type TeacherCatalogState } from '@/lib/coding-teacher';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { AppError } from '@/lib/error-model'; import { AppError } from '@/lib/error-model';
import { import {
@@ -11,8 +11,12 @@ import {
} from '../../../shared/coding-teacher'; } from '../../../shared/coding-teacher';
interface CompanionState { interface CompanionState {
catalogState?: TeacherCatalogState;
refreshing: boolean;
refreshStatus: 'idle' | 'success' | 'error';
agents: TeacherCatalog['items']; agents: TeacherCatalog['items'];
selectedAgent?: TeacherCatalog['items'][number]; // null keeps an already-open, unassigned editor stable when the first Agent arrives.
selectedAgent?: TeacherCatalog['items'][number] | null;
definition: TeacherDefinition | null; definition: TeacherDefinition | null;
enabled: boolean; enabled: boolean;
loading: boolean; loading: boolean;
@@ -47,7 +51,7 @@ export function useTeacherCompanion(options: Options) {
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out'); const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
const scope = `${account}:${options.projectId ?? ''}`; const scope = `${account}:${options.projectId ?? ''}`;
const storageKey = `makelore-teacher-presence:${scope}`; const storageKey = `makelore-teacher-presence:${scope}`;
const [state, setState] = useState<CompanionState>({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: false, error: '' }); const [state, setState] = useState<CompanionState>({ agents: [], definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen: [], welcomeDismissed: false, error: '' });
const selectionEpoch = useRef(0); const selectionEpoch = useRef(0);
const latest = useRef({ options, state }); const latest = useRef({ options, state });
latest.current = { options, state }; latest.current = { options, state };
@@ -55,6 +59,32 @@ export function useTeacherCompanion(options: Options) {
scopeRef.current = scope; scopeRef.current = scope;
const stateScope = useRef(scope); const stateScope = useRef(scope);
const inFlight = useRef(false); const inFlight = useRef(false);
const configLoader = useRef<(() => Promise<{ enabled: boolean }>) | null>(null);
const reloadCatalog = useCallback(async () => { await configLoader.current?.(); }, []);
const refreshFlight = useRef<{ load: NonNullable<typeof configLoader.current>; promise: Promise<void> } | null>(null);
const refresh = useCallback((): Promise<void> => {
const load = configLoader.current;
if (!load) return Promise.resolve();
const refreshingScope = scopeRef.current;
const isCurrent = () => configLoader.current === load && scopeRef.current === refreshingScope;
if (refreshFlight.current?.load === load) return refreshFlight.current.promise;
setState((current) => ({ ...current, refreshing: true, refreshStatus: 'idle' }));
const promise = load().then(() => {
if (isCurrent()) setState((current) => ({ ...current, refreshStatus: 'success' }));
}).catch(() => {
if (isCurrent()) setState((current) => ({ ...current, refreshStatus: 'error' }));
}).finally(() => {
if (isCurrent()) setState((current) => ({ ...current, refreshing: false }));
if (refreshFlight.current?.promise === promise) refreshFlight.current = null;
});
refreshFlight.current = { load, promise };
return promise;
}, []);
useEffect(() => {
if (state.refreshStatus !== 'success') return;
const timer = window.setTimeout(() => setState((current) => ({ ...current, refreshStatus: 'idle' })), 3000);
return () => window.clearTimeout(timer);
}, [state.refreshStatus]);
const noteTopic = useCallback((topic: TeacherTopic) => { const noteTopic = useCallback((topic: TeacherTopic) => {
if (scopeRef.current !== scope || topic.projectId !== options.projectId) return; if (scopeRef.current !== scope || topic.projectId !== options.projectId) return;
setState((current) => current.topic?.id === topic.id && current.topic.revision >= topic.revision setState((current) => current.topic?.id === topic.id && current.topic.revision >= topic.revision
@@ -64,7 +94,7 @@ export function useTeacherCompanion(options: Options) {
}, [scope, options.projectId]); }, [scope, options.projectId]);
const selectAgent = useCallback((id: string) => { const selectAgent = useCallback((id: string) => {
const current = latest.current.state; const current = latest.current.state;
if (id === (current.selectedAgent?.teacher_id ?? current.topic?.definition.config_id ?? current.definition?.config_id)) return; if (id === (current.selectedAgent?.teacher_id ?? current.topic?.definition.config_id)) return;
const agent = current.agents.find((item) => item.teacher_id === id); const agent = current.agents.find((item) => item.teacher_id === id);
if (!agent) return; if (!agent) return;
selectionEpoch.current++; selectionEpoch.current++;
@@ -93,24 +123,38 @@ export function useTeacherCompanion(options: Options) {
const welcomeDismissed = readLocal<boolean>(storageKey + ':welcome-dismissed', false) === true; const welcomeDismissed = readLocal<boolean>(storageKey + ':welcome-dismissed', false) === true;
selectionEpoch.current++; selectionEpoch.current++;
const initialSelection = selectionEpoch.current; const initialSelection = selectionEpoch.current;
setState({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen, welcomeDismissed, error: '' }); setState({ agents: [], definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen, welcomeDismissed, error: '' });
const projectId = options.projectId; const projectId = options.projectId;
if (!projectId) { if (!projectId) {
setState((current) => ({ ...current, loading: false })); setState((current) => ({ ...current, loading: false }));
return () => { alive = false; }; return () => { alive = false; };
} }
const base = teacherTopicsPath(projectId); const base = teacherTopicsPath(projectId);
const loadConfig = async () => { let configFlight: Promise<{ enabled: boolean }> | null = null;
const config = await teacherApi.config(); const loadConfig = () => {
const catalog = await teacherApi.catalog(); if (configFlight) return configFlight;
const selectedId = latest.current.state.selectedAgent?.teacher_id; configFlight = (async () => {
const selected = catalog.items.find((item) => item.teacher_id === selectedId) const [config, catalog] = await Promise.all([teacherApi.config(), teacherApi.catalog()]);
?? (!selectedId ? catalog.items.find((item) => item.is_default) ?? catalog.items[0] : undefined); if (!alive || scopeRef.current !== scope) return { enabled: false };
const enabled = selected ? true : !selectedId && config.enabled; const select = (current: CompanionState) => {
if (alive) setState((current) => ({ ...current, agents: catalog.items, const id = current.selectedAgent?.teacher_id ?? current.topic?.definition.config_id;
definition: selected?.definition ?? current.selectedAgent?.definition ?? current.topic?.definition ?? config.definition, enabled, loading: false })); return catalog.items.find((item) => item.teacher_id === id)
return { enabled }; ?? (!id ? catalog.items.find((item) => item.is_default) ?? catalog.items[0] : undefined);
};
const isEnabled = (current: CompanionState) => Boolean(select(current))
|| (!current.selectedAgent && !current.topic?.definition.config_id && config.enabled);
const enabled = isEnabled(latest.current.state);
setState((current) => ({ ...current, catalogState: { config, catalog }, agents: catalog.items,
selectedAgent: current.selectedAgent ? select(current) ?? current.selectedAgent
: (!current.agents.length && !current.topic?.definition.config_id && latest.current.options.teacherOpen)
? null : current.selectedAgent,
definition: select(current)?.definition ?? current.selectedAgent?.definition ?? current.topic?.definition ?? config.definition,
enabled: isEnabled(current), loading: false, error: '' }));
return { enabled };
})().finally(() => { configFlight = null; });
return configFlight;
}; };
configLoader.current = loadConfig;
void (async () => { void (async () => {
try { try {
await loadConfig(); await loadConfig();
@@ -183,6 +227,7 @@ export function useTeacherCompanion(options: Options) {
document.addEventListener('visibilitychange', onVisible); document.addEventListener('visibilitychange', onVisible);
return () => { return () => {
alive = false; alive = false;
if (configLoader.current === loadConfig) configLoader.current = null;
window.clearInterval(timer); window.clearInterval(timer);
window.removeEventListener('focus', onVisible); window.removeEventListener('focus', onVisible);
document.removeEventListener('visibilitychange', onVisible); document.removeEventListener('visibilitychange', onVisible);
@@ -211,12 +256,12 @@ export function useTeacherCompanion(options: Options) {
const last = topic?.requests.at(-1); const last = topic?.requests.at(-1);
const invitation = unreadInvitation({ topic, seen: state.seen }); const invitation = unreadInvitation({ topic, seen: state.seen });
const visibleState = stateScope.current === scope ? state : { agents: [], selectedAgent: undefined, definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: true, error: '' }; const visibleState: CompanionState = stateScope.current === scope ? state : { agents: [], selectedAgent: undefined, definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen: [], welcomeDismissed: true, error: '' };
// This is the published greeting, not a fabricated project assessment. // This is the published greeting, not a fabricated project assessment.
const selectedDefinition = topic?.definition ?? visibleState.definition; const selectedDefinition = topic?.definition ?? visibleState.definition;
const selectedAgentId = visibleState.selectedAgent?.teacher_id ?? selectedDefinition?.config_id; const selectedAgentId = visibleState.selectedAgent?.teacher_id ?? selectedDefinition?.config_id;
const selectedAgent = visibleState.agents.find((item) => item.teacher_id === selectedAgentId) const selectedAgent = visibleState.selectedAgent === null ? undefined
?? visibleState.selectedAgent; : visibleState.agents.find((item) => item.teacher_id === selectedAgentId) ?? visibleState.selectedAgent;
const welcome = topic?.role !== 'friend' && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed const welcome = topic?.role !== 'friend' && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed
? selectedDefinition?.welcome_message.trim() || null : null; ? selectedDefinition?.welcome_message.trim() || null : null;
return { return {
@@ -224,6 +269,8 @@ export function useTeacherCompanion(options: Options) {
error: last?.intent === 'check-in' && ['failed', 'interrupted'].includes(last.status) ? '智能体这次没连上,可以点头像找智能体聊聊。' : visibleState.error, error: last?.intent === 'check-in' && ['failed', 'interrupted'].includes(last.status) ? '智能体这次没连上,可以点头像找智能体聊聊。' : visibleState.error,
definition: selectedDefinition, definition: selectedDefinition,
selectedAgent, selectedAgent,
refresh,
reloadCatalog,
selectAgent, selectAgent,
topic, topic,
invitation, invitation,
+13 -1
View File
@@ -14,7 +14,9 @@ export function Layout() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [width, setWidth] = useState(508); const [width, setWidth] = useState(508);
const [long, setLong] = useState(true); const [long, setLong] = useState(true);
const count = Number(new URLSearchParams(location.search).get('agents') ?? 0); const [count, setCount] = useState(Number(new URLSearchParams(location.search).get('agents') ?? 0));
const [refreshing, setRefreshing] = useState(false);
const [refreshStatus, setRefreshStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [selected, setSelected] = useState(0); const [selected, setSelected] = useState(0);
const agents = Array.from({ length: count }, (_, index) => ({ const agents = Array.from({ length: count }, (_, index) => ({
teacher_id: `agent-${index}`, version: index + 1, is_default: index === 0, teacher_id: `agent-${index}`, version: index + 1, is_default: index === 0,
@@ -27,6 +29,16 @@ export function Layout() {
definition: agents[selected]?.definition ?? { name: '编程老师', avatar_id: 'avatar-06' }, definition: agents[selected]?.definition ?? { name: '编程老师', avatar_id: 'avatar-06' },
invitation: visible ? { response: long ? '你刚才说想做一个宠物游戏。我们一起想想,什么样的小动作,会让你愿意一直陪它玩下去?可以先挑一个最想试的想法。' : '有什么编程问题想一起讨论?' } : null, invitation: visible ? { response: long ? '你刚才说想做一个宠物游戏。我们一起想想,什么样的小动作,会让你愿意一直陪它玩下去?可以先挑一个最想试的想法。' : '有什么编程问题想一起讨论?' } : null,
welcome: null, enabled: true, loading: false, checking: false, error: null, welcome: null, enabled: true, loading: false, checking: false, error: null,
refreshing, refreshStatus,
refresh: async () => {
setRefreshing(true);
setRefreshStatus('idle');
await new Promise((resolve) => window.setTimeout(resolve, 100));
setCount((current) => current + 1);
setRefreshStatus('success');
setRefreshing(false);
},
reloadCatalog: async () => {},
dismiss: () => setVisible(false), dismiss: () => setVisible(false),
} as ComponentProps<typeof TeacherCompanion>['companion']; } as ComponentProps<typeof TeacherCompanion>['companion'];
return <div className="classroom-workspace flex h-screen flex-col bg-white"> return <div className="classroom-workspace flex h-screen flex-col bg-white">
+128 -6
View File
@@ -4,6 +4,7 @@ import { createServer } from 'node:http';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import type { NativeAgentBrowserFixture } from './fixtures/native-agent-browser'; import type { NativeAgentBrowserFixture } from './fixtures/native-agent-browser';
import type { TeacherCatalog, TeacherDefinition } from '../../shared/coding-teacher';
import { expect, getStableWindow, test } from './fixtures/electron'; import { expect, getStableWindow, test } from './fixtures/electron';
type CapturedRequest = { type CapturedRequest = {
@@ -20,6 +21,13 @@ type HostConnection = {
token: string; token: string;
}; };
type TeacherCatalogFixture = {
teacherCatalog: TeacherCatalog;
teacherCatalogFailure: boolean;
holdTeacherCatalog: boolean;
releaseTeacherCatalog: (() => void) | null;
};
async function disableCodingEventSource(page: Page): Promise<void> { async function disableCodingEventSource(page: Page): Promise<void> {
await page.addInitScript(() => { await page.addInitScript(() => {
const sources = new Set<LocalEventSource>(); const sources = new Set<LocalEventSource>();
@@ -92,7 +100,7 @@ async function installCodingFirstChatHost(
await electronApp.evaluate(async (_, payload) => { await electronApp.evaluate(async (_, payload) => {
const { connection, featureComplete, managedCapabilities, removedModel, audioPreview } = payload; const { connection, featureComplete, managedCapabilities, removedModel, audioPreview } = payload;
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type MainState = { type MainState = TeacherCatalogFixture & {
captured: CapturedRequest[]; captured: CapturedRequest[];
conversationCreated: boolean; conversationCreated: boolean;
interactionAnswered: boolean; interactionAnswered: boolean;
@@ -116,6 +124,10 @@ async function installCodingFirstChatHost(
releaseSnapshot: null, releaseSnapshot: null,
snapshotPending: false, snapshotPending: false,
snapshotSettled: false, snapshotSettled: false,
teacherCatalog: { items: [] },
teacherCatalogFailure: false,
holdTeacherCatalog: false,
releaseTeacherCatalog: null,
}; };
mainGlobal.__makelorePiFirstChatE2E = state; mainGlobal.__makelorePiFirstChatE2E = state;
const now = '2026-08-24T00:00:00.000Z'; const now = '2026-08-24T00:00:00.000Z';
@@ -454,8 +466,8 @@ async function installCodingFirstChatHost(
...overrides, ...overrides,
}); });
const teacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'一起发现代码里的小奥秘,把想法变成作品。',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}}; const teacherDefinition: TeacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'一起发现代码里的小奥秘,把想法变成作品。',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
const teachers = [ state.teacherCatalog.items = [
{ teacher_id: 'teacher-code', version: 1, is_default: true, definition: { ...teacherDefinition, config_id: 'teacher-code', runtime: 'yuxi', system_prompt: '', yuxi: { agent_slug: 'code', agent_version: 2 } } }, { teacher_id: 'teacher-code', version: 1, is_default: true, definition: { ...teacherDefinition, config_id: 'teacher-code', runtime: 'yuxi', system_prompt: '', yuxi: { agent_slug: 'code', agent_version: 2 } } },
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '朋友', description: '陪你听听玩家的想法,让作品更有趣。', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } }, { teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '朋友', description: '陪你听听玩家的想法,让作品更有趣。', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
]; ];
@@ -533,11 +545,20 @@ async function installCodingFirstChatHost(
} }
if (path.endsWith('/teacher-check-in') && method === 'POST') { if (path.endsWith('/teacher-check-in') && method === 'POST') {
const current = consultationTopics.agent ?? {schemaVersion:1,revision:0,id:'agent-topic',accountId:'e2e',projectId:project.id,sourceConversationId:'project',definition:teacherDefinition,version:1,createdAt:now,updatedAt:now,requests:[]}; const published = state.teacherCatalog.items.find((item) => item.is_default) ?? state.teacherCatalog.items[0];
const current = consultationTopics.agent ?? {schemaVersion:1,revision:0,id:'agent-topic',accountId:'e2e',projectId:project.id,sourceConversationId:'project',definition:published?.definition ?? teacherDefinition,version:published?.version ?? 1,createdAt:now,updatedAt:now,requests:[]};
consultationTopics.agent = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,intent:'check-in',text:'',references:[],sourceConversationId:body!.sourceConversationId,createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,status:'completed',response:'你刚才想加排行榜,我们一起想想比什么更有意思?'}]}; consultationTopics.agent = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,intent:'check-in',text:'',references:[],sourceConversationId:body!.sourceConversationId,createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,status:'completed',response:'你刚才想加排行榜,我们一起想想比什么更有意思?'}]};
return respond({topic:consultationTopics.agent}); return respond({topic:consultationTopics.agent});
} }
if (path === '/api/coding/teacher/teachers') return respond({ items: teachers }); if (path === '/api/coding/teacher/teachers') {
if (state.holdTeacherCatalog) {
await new Promise<void>((resolveCatalog) => { state.releaseTeacherCatalog = resolveCatalog; });
state.releaseTeacherCatalog = null;
state.holdTeacherCatalog = false;
}
if (state.teacherCatalogFailure) return respond({ error: 'Catalog unavailable' }, 503);
return respond(state.teacherCatalog);
}
if (path === '/api/coding/teacher/config') return respond({enabled:true,revision:1,published_version:1,definition:teacherDefinition}); if (path === '/api/coding/teacher/config') return respond({enabled:true,revision:1,published_version:1,definition:teacherDefinition});
const consultationPath = path.match(/\/(agent|teacher)-topics(?:\/|$)/)?.[1]; const consultationPath = path.match(/\/(agent|teacher)-topics(?:\/|$)/)?.[1];
if (consultationPath) { if (consultationPath) {
@@ -546,7 +567,7 @@ async function installCodingFirstChatHost(
const currentTopic = consultationTopics[consultationPath]; const currentTopic = consultationTopics[consultationPath];
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 === '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') { if (path.endsWith(topicBase) && method === 'POST') {
const selected = teachers.find(item => item.version === body?.teacherVersion) ?? teachers[0]; const selected = state.teacherCatalog.items.find(item => item.version === body?.teacherVersion) ?? state.teacherCatalog.items[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:[]}; 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:[]};
return respond(consultationTopics[consultationPath],201); return respond(consultationTopics[consultationPath],201);
} }
@@ -1864,6 +1885,107 @@ test('project consultations preserve student drafts and switch between work and
}); });
test('manual agent refresh updates the catalog and retries failures without replacing the open topic or drafts', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, connection, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
try {
await page.reload();
page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
await expect(page.getByTestId('project-conversations')).toBeVisible();
const composer = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
await composer.fill('主会话还没发出的想法');
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
const teacher = page.getByTestId('teacher-chat-panel');
await teacher.getByRole('button', { name: '解释当前代码', exact: true }).click();
await teacher.getByRole('button', { name: '提问', exact: true }).click();
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
const draft = teacher.getByRole('textbox', { name: '向智能体提问' });
await draft.fill('智能体聊天里还没说完的话');
const before = (await readState(electronApp)).captured.length;
await electronApp.evaluate(() => {
const state = (globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture }).__makelorePiFirstChatE2E;
const original = state.teacherCatalog.items[0];
state.teacherCatalog.items = [
{ ...original, version: 2, definition: { ...original.definition, name: '新版代码智能体', description: '更新后的编程伙伴介绍。' } },
state.teacherCatalog.items[1],
{ teacher_id: 'teacher-science', version: 15, is_default: false,
definition: { ...original.definition, config_id: 'teacher-science', name: '科学智能体', welcome_message: '一起探索科学', description: '刚刚下发的新智能体。' } },
];
state.holdTeacherCatalog = true;
});
const refresh = page.getByRole('button', { name: '刷新智能体', exact: true });
await refresh.click();
await expect(refresh).toBeDisabled();
await expect(page.getByRole('status').filter({ hasText: '正在刷新' })).toBeVisible();
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
await expect.poll(() => electronApp.evaluate(() => Boolean(
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.releaseTeacherCatalog,
))).toBe(true);
await electronApp.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.releaseTeacherCatalog?.();
});
await expect(refresh).toBeEnabled();
await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible();
await expect(page.getByRole('button', { name: '与科学智能体聊天', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '与新版代码智能体聊天', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: '与代码智能体聊天', exact: true })).toHaveCount(0);
// Existing discussions keep their original definition and all unsent text.
await expect(teacher).toHaveAccessibleName('代码智能体咨询');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
await expect(composer).toHaveValue('主会话还没发出的想法');
const refreshedRequests = (await readState(electronApp)).captured.slice(before);
expect(refreshedRequests.filter((request) => request.path === '/api/coding/teacher/teachers')).toHaveLength(1);
expect(refreshedRequests.filter((request) => request.path === '/api/coding/teacher/config')).toHaveLength(1);
expect(refreshedRequests.filter((request) => /\/(agent-topics|messages|teacher-check-in|prompt)$/.test(request.path) && request.method === 'POST')).toHaveLength(0);
expect(refreshedRequests.filter((request) => /\/agent-topics(?:\/|$)/.test(request.path))).toHaveLength(0);
await page.screenshot({ path: test.info().outputPath('manual-agent-refresh.png') });
await electronApp.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.teacherCatalogFailure = true;
});
await refresh.click();
await expect(page.getByRole('status').filter({ hasText: '刷新失败,请重试' })).toBeVisible();
await expect(refresh).toBeEnabled();
await expect(page.getByRole('button', { name: '与科学智能体聊天', exact: true })).toBeVisible();
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await electronApp.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.teacherCatalogFailure = false;
});
await refresh.click();
await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible();
await page.getByRole('button', { name: '与科学智能体聊天', exact: true }).click();
await expect(teacher).toHaveAccessibleName('科学智能体咨询');
await expect(teacher.getByText('一起探索科学')).toBeVisible();
await page.getByRole('button', { name: '与新版代码智能体聊天', exact: true }).click();
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(composer).toHaveValue('主会话还没发出的想法');
const finalRequests = (await readState(electronApp)).captured.slice(before);
expect(finalRequests.filter((request) => /\/(messages|teacher-check-in|prompt)$/.test(request.path) && request.method === 'POST')).toHaveLength(0);
} finally {
await electronApp.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E?: TeacherCatalogFixture })
.__makelorePiFirstChatE2E?.releaseTeacherCatalog?.();
}).catch(() => undefined);
await releaseSnapshot(electronApp);
}
});
test('settled prompt releases the composer without a client receipt and keeps the next draft', async ({ launchElectronApp }) => { test('settled prompt releases the composer without a client receipt and keeps the next draft', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true }); const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp); let page = await getStableWindow(electronApp);
+13 -1
View File
@@ -40,7 +40,10 @@ for (const width of [520, 1000]) {
await page.setViewportSize({ width, height: 800 }); await page.setViewportSize({ width, height: 800 });
await page.goto(baseUrl + '?agents=8'); await page.goto(baseUrl + '?agents=8');
const tabs = page.getByRole('navigation', { name: '已下发智能体' }); const tabs = page.getByRole('navigation', { name: '已下发智能体' });
const refresh = page.getByRole('button', { name: '刷新智能体', exact: true });
await expect(tabs.getByRole('button')).toHaveCount(8); await expect(tabs.getByRole('button')).toHaveCount(8);
await expect(refresh).toBeVisible();
await expect(tabs.getByRole('button', { name: '刷新智能体', exact: true })).toHaveCount(0);
await expect(page.getByRole('combobox', { name: '新话题使用的智能体' })).toHaveCount(0); await expect(page.getByRole('combobox', { name: '新话题使用的智能体' })).toHaveCount(0);
const last = tabs.getByRole('button', { name: '与智能体8聊天' }); const last = tabs.getByRole('button', { name: '与智能体8聊天' });
await last.focus(); await last.focus();
@@ -48,13 +51,22 @@ for (const width of [520, 1000]) {
await expect(last).toHaveAttribute('aria-pressed', 'true'); await expect(last).toHaveAttribute('aria-pressed', 'true');
const bounds = await page.evaluate(() => { const bounds = await page.evaluate(() => {
const strip = document.querySelector('.consultation-agent-tabs')!; const strip = document.querySelector('.consultation-agent-tabs')!;
const refresh = document.querySelector('button[aria-label="刷新智能体"]')!;
const controls = document.querySelector('[data-testid="window-controls"]')!; const controls = document.querySelector('[data-testid="window-controls"]')!;
return { right: strip.getBoundingClientRect().right, controlsLeft: controls.getBoundingClientRect().left, return { right: strip.getBoundingClientRect().right, controlsLeft: controls.getBoundingClientRect().left,
refreshLeft: refresh.getBoundingClientRect().left, refreshRight: refresh.getBoundingClientRect().right,
scroll: strip.scrollLeft, documentWidth: document.documentElement.scrollWidth }; scroll: strip.scrollLeft, documentWidth: document.documentElement.scrollWidth };
}); });
expect(bounds.right).toBeLessThanOrEqual(bounds.controlsLeft); expect(bounds.right).toBeLessThanOrEqual(bounds.refreshLeft);
expect(bounds.refreshRight).toBeLessThanOrEqual(bounds.controlsLeft);
expect(bounds.documentWidth).toBeLessThanOrEqual(width); expect(bounds.documentWidth).toBeLessThanOrEqual(width);
expect(bounds.scroll).toBeGreaterThan(0); expect(bounds.scroll).toBeGreaterThan(0);
await refresh.click();
await expect(tabs.getByRole('button')).toHaveCount(9);
await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible();
await expect(last).toHaveAttribute('aria-pressed', 'true');
await expect(refresh).toBeInViewport();
await expect(refresh).toBeEnabled();
await page.screenshot({ path: test.info().outputPath('distributed-agents-titlebar.png') }); await page.screenshot({ path: test.info().outputPath('distributed-agents-titlebar.png') });
}); });
} }
+156 -2
View File
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useTeacherCompanion } from '@/pages/Chat/use-teacher-companion'; import { useTeacherCompanion } from '@/pages/Chat/use-teacher-companion';
import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion'; import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel'; import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher'; import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherCatalog, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
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() })); 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('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project }));
@@ -25,7 +25,7 @@ function Harness({ projectId = 'project', sourceId = 'source', busy = false, arc
if (open && (!id || id === companion.selectedAgent?.teacher_id)) setOpen(false); if (open && (!id || id === companion.selectedAgent?.teacher_id)) setOpen(false);
else { if (id) companion.selectAgent(id); setOpen(true); } else { if (id) companion.selectAgent(id); setOpen(true); }
}} /> }} />
{open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} />}</>; {open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} catalogState={companion.catalogState} reloadCatalog={companion.reloadCatalog} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} />}</>;
} }
async function mount(props: Parameters<typeof Harness>[0] = {}) { async function mount(props: Parameters<typeof Harness>[0] = {}) {
let view!: ReturnType<typeof render>; let view!: ReturnType<typeof render>;
@@ -51,6 +51,160 @@ beforeEach(() => {
}); });
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); }); afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
describe('manual agent refresh', () => {
it('refreshes while busy and composing, shares one request, and uses the new version for the first question', async () => {
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师', welcome_message: '一起想想办法' } };
const updated = { ...original, version: 4, definition: { ...original.definition, name: '方法伙伴', welcome_message: '我们一起试试新办法' } };
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
api.catalog.mockResolvedValue({ items: [original] });
await mount({ busy: true });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我正在写的问题' } });
const listCalls = api.list.mock.calls.length;
const catalogCalls = api.catalog.mock.calls.length;
let resolve!: (catalog: TeacherCatalog) => void;
api.catalog.mockImplementationOnce(() => new Promise<TeacherCatalog>((done) => { resolve = done; }));
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeDisabled();
expect(screen.getByText('正在刷新…', { selector: '[role="status"]' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '刷新智能体' }));
expect(api.catalog).toHaveBeenCalledTimes(catalogCalls + 1);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我正在写的问题');
await act(async () => resolve({ items: [updated, added] }));
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
expect(screen.getByRole('complementary', { name: '方法伙伴咨询' })).toBeVisible();
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(updated.definition.welcome_message)).toBeVisible();
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我正在写的问题');
expect(api.list).toHaveBeenCalledTimes(listCalls);
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
const created = { ...checkInTopic(), id: 'new-topic', definition: updated.definition, version: updated.version, requests: [] };
api.create.mockResolvedValue(created);
api.send.mockResolvedValue(created);
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
expect(api.create).toHaveBeenCalledWith('project', undefined, undefined, updated.version);
expect(api.send).toHaveBeenCalledWith('project', created.id, expect.objectContaining({ text: '我正在写的问题' }));
});
it('preserves the selected pinned topic, unread reply and draft until the user starts a new topic', async () => {
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
const updated = { ...original, version: 4, definition: { ...original.definition, name: '方法伙伴' } };
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
const pinned = { ...checkInTopic(), version: 1, definition: original.definition };
api.catalog.mockResolvedValue({ items: [original] });
api.list.mockResolvedValue({ items: [{ id: pinned.id, teacherId: original.teacher_id, title: '之前的讨论' }], lastSelectedTopicId: pinned.id });
api.read.mockResolvedValue(pinned);
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '接着刚才说' } });
const readCalls = api.read.mock.calls.length;
const listCalls = api.list.mock.calls.length;
api.catalog.mockResolvedValue({ items: [updated, added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('complementary', { name: '方法老师咨询' })).toBeVisible();
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(pinned.requests[0].response)).toBeVisible();
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent(pinned.requests[0].response);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('接着刚才说');
expect(api.read).toHaveBeenCalledTimes(readCalls);
expect(api.list).toHaveBeenCalledTimes(listCalls);
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
api.send.mockResolvedValue(pinned);
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
expect(api.send).toHaveBeenCalledWith('project', pinned.id, expect.objectContaining({ text: '接着刚才说' }));
expect(api.create).not.toHaveBeenCalled();
api.create.mockResolvedValue({ ...pinned, id: 'new-topic', version: updated.version, definition: updated.definition, requests: [] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '以往讨论' })));
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体新话题' })));
expect(api.create).toHaveBeenCalledWith('project', undefined, undefined, updated.version);
expect(screen.getByRole('complementary', { name: '方法伙伴咨询' })).toBeVisible();
});
it.each(['catalog', 'config'] as const)('keeps the current catalog when %s fails and lets the user retry', async (failedEndpoint) => {
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
api.catalog.mockResolvedValue({ items: [original] });
await mount();
api.catalog.mockResolvedValue({ items: [original, added] });
api[failedEndpoint].mockRejectedValueOnce(new Error('offline'));
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByText('刷新失败,请重试')).toBeVisible();
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent('方法老师');
expect(screen.queryByRole('button', { name: '与体验朋友聊天' })).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
expect(screen.queryByText('刷新失败,请重试')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('can fetch the first delivered agent while the previous catalog was unavailable', async () => {
api.config.mockResolvedValue({ enabled: false, definition: null });
await mount();
const added = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
api.catalog.mockResolvedValue({ items: [added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent('体验朋友');
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('keeps an open project draft in place when the first delivered agent appears', async () => {
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
const input = screen.getByLabelText('向智能体提问');
fireEvent.change(input, { target: { value: '还没有选择智能体的问题' } });
const added = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
api.catalog.mockResolvedValue({ items: [added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByLabelText('向智能体提问')).toBe(input);
expect(input).toHaveValue('还没有选择智能体的问题');
expect(screen.queryByText('查看之前的项目草稿')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(added.definition.name);
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-pressed', 'false');
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByLabelText('向智能体提问')).toBe(input);
expect(input).toHaveValue('还没有选择智能体的问题');
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
expect(screen.getByText('查看之前的项目草稿')).toBeVisible();
expect(localStorage.getItem('makelore-consultation-draft:kid:project:teacher')).toContain('还没有选择智能体的问题');
});
it('ignores a slow refresh from the previous project without changing the new project status', async () => {
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '旧项目智能体' } };
const current = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '当前项目智能体' } };
api.catalog.mockResolvedValue({ items: [original] });
const view = await mount();
let resolve!: (catalog: TeacherCatalog) => void;
api.catalog.mockImplementationOnce(() => new Promise<TeacherCatalog>((done) => { resolve = done; }));
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
api.catalog.mockResolvedValue({ items: [current] });
await act(async () => view.rerender(<Harness projectId="other" />));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(current.definition.name);
await act(async () => resolve({ items: [original] }));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(current.definition.name);
expect(screen.queryByText(original.definition.name)).not.toBeInTheDocument();
expect(screen.queryByText('已更新', { selector: '[role="status"]' })).not.toBeInTheDocument();
expect(screen.queryByText('正在刷新…', { selector: '[role="status"]' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
expect(api.checkIn).not.toHaveBeenCalled();
});
});
describe('teacher presence and periodic guidance', () => { describe('teacher presence and periodic guidance', () => {
it('shows the hovered agent introduction in a portal without selecting it or sending a request', async () => { it('shows the hovered agent introduction in a portal without selecting it or sending a request', async () => {
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师', description: '把大问题拆成小积木,一起试试看。' } }; const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师', description: '把大问题拆成小积木,一起试试看。' } };