diff --git a/.project-docs/30-worklog/tasks/20260928-agent-refresh-a6d109b3.md b/.project-docs/30-worklog/tasks/20260928-agent-refresh-a6d109b3.md new file mode 100644 index 00000000..308fc4c2 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-agent-refresh-a6d109b3.md @@ -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. diff --git a/README.md b/README.md index 6d4af5b0..35c6a6db 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p ### 下发智能体咨询 +顶部智能体名单旁的“刷新”按钮可立即获取最新下发名单、发布资料和新话题版本,独立于主动关心的检查条件。刷新中显示进度,成功提示“已更新”,失败保留原名单并可重试;顶部和已打开的咨询面板使用同一份结果。刷新不发起模型请求、不切换当前智能体、不清除草稿或历史,已有话题继续使用创建时绑定的版本。 + 老师 Agent 是某一领域杰出人物的能力转化而成的智能体,供平台学生使用,在真实项目、作品和问题中提供专业理解、判断与解决问题的能力支持。定义以人的领域能力及学生价值为中心;蒸馏属于实现方法,云端下发、上下文读取和聊天是能力交付方式。完整理念见[老师 Agent 产品定位](.project-docs/00-brief/project-positioning.md#老师-agent-的产品定位)。 - 新话题可选择运营下发的 Yuxi 智能体,默认使用指定智能体;提示词、Skills、知识库和模型配置由 Yuxi 管理,运营同步后生成新版本,已有话题固定原版本。Yuxi 智能体模型费用由当前学生的编程词元点数支付,个人 Agents 模块仍由创建者付费;不要求学生开通个人云智能体模块。停用的智能体仍可查看历史,不能借另一位智能体的可用状态继续提问。 diff --git a/src/lib/coding-teacher.ts b/src/lib/coding-teacher.ts index 6b445db9..1449f23d 100644 --- a/src/lib/coding-teacher.ts +++ b/src/lib/coding-teacher.ts @@ -10,6 +10,10 @@ import type { TeacherTopic, TeacherTopicList, } from '../../shared/coding-teacher'; +export interface TeacherCatalogState { + config: TeacherAvailability & { definition: TeacherDefinition | null }; + catalog: TeacherCatalog; +} export function teacherTopicsPath(projectId: string) { return projectId === 'preview' ? '/api/coding/teacher-preview/topics' diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 7418bd1b..13a1a62b 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -972,6 +972,8 @@ export function CodingChatPanel({ ; }) { const base = teacherTopicsPath(projectId); const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out'); @@ -60,7 +64,9 @@ export function TeacherChatPanel({ const discussionEnabled = !draftRevision; const [historyOpen, setHistoryOpen] = useState(false); const [focus, setFocus] = useState<{ topicId: string; id: string; label: string } | null>(null); - const [definition, setDefinition] = useState(null); + const [localDefinition, setDefinition] = useState(null); + const catalogStateRef = useRef(catalogState); + catalogStateRef.current = catalogState; const externalTopicRef = useRef(externalTopic); externalTopicRef.current = externalTopic; const selectedAgentId = selectedAgent?.teacher_id; @@ -80,10 +86,20 @@ export function TeacherChatPanel({ draftRef.current = { text, references }; const [error, setError] = useState(''); const [busy, setBusy] = useState(true); - const [enabled, setEnabled] = useState(false); - const [legacyEnabled, setLegacyEnabled] = useState(false); - const [teachers, setTeachers] = useState([]); - const [selectedTeacherVersion, setSelectedTeacherVersion] = useState(); + const [localEnabled, setEnabled] = useState(false); + const [localLegacyEnabled, setLegacyEnabled] = useState(false); + const [localTeachers, setTeachers] = useState([]); + const [localTeacherVersion, setSelectedTeacherVersion] = useState(); + // 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 : topic.definition.config_id ? teachers.some((item) => item.teacher_id === topic.definition.config_id) @@ -140,25 +156,31 @@ export function TeacherChatPanel({ setBusy(true); void (async () => { try { - const config = draftRevision - ? { definition: (await teacherApi.preview(draftRevision)).payload, enabled: true } - : await teacherApi.config(); - if (alive) { - setDefinition(config.definition); - setEnabled(config.enabled); - setLegacyEnabled(config.enabled); - } - if (!draftRevision) { - const catalog = await teacherApi.catalog(); + if (!draftRevision && reloadCatalog) { + // Opening the panel still checks for updates through the shared loader. + // A failed background read leaves the last usable catalog in place. + await reloadCatalog().catch(() => undefined); + } else if (draftRevision || !catalogStateRef.current) { + const config = draftRevision + ? { definition: (await teacherApi.preview(draftRevision)).payload, enabled: true } + : await teacherApi.config(); if (alive) { - 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); + setDefinition(config.definition); + setEnabled(config.enabled); + setLegacyEnabled(config.enabled); + } + if (!draftRevision) { + const catalog = await teacherApi.catalog(); + if (alive) { + 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) { @@ -193,7 +215,7 @@ export function TeacherChatPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps generation.current++; }; - }, [base, draftRevision, label, consume, selectedAgentId]); + }, [base, draftRevision, label, consume, selectedAgentId, reloadCatalog]); useEffect(() => { if (!topic?.id || legacyFriend) return; const streamTopicId = topic.id; diff --git a/src/pages/Chat/TeacherCompanion.tsx b/src/pages/Chat/TeacherCompanion.tsx index 1e07de17..bf1fb2e9 100644 --- a/src/pages/Chat/TeacherCompanion.tsx +++ b/src/pages/Chat/TeacherCompanion.tsx @@ -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 { Provider as TooltipProvider } from '@radix-ui/react-tooltip'; import { TeacherAvatar } from './TeacherAvatar'; @@ -28,6 +28,9 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef, const status = loading ? '正在连接智能体…' : checking ? '智能体在看看你最近的进展…' : error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说'); + const refreshMessage = companion.refreshing ? '正在刷新…' + : companion.refreshStatus === 'error' ? '刷新失败,请重试' + : companion.refreshStatus === 'success' ? '已更新' : ''; return (
@@ -50,6 +53,13 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef, {checking && } } + + {refreshMessage} {(invitation || welcome) &&
diff --git a/src/pages/Chat/TeacherIntroduction.tsx b/src/pages/Chat/TeacherIntroduction.tsx index 3cf16ef5..007e9653 100644 --- a/src/pages/Chat/TeacherIntroduction.tsx +++ b/src/pages/Chat/TeacherIntroduction.tsx @@ -1,6 +1,6 @@ import * as Tooltip from '@radix-ui/react-tooltip'; 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 { TeacherAvatar } from './TeacherAvatar'; @@ -13,11 +13,23 @@ export function TeacherIntroduction({ definition, children, contentRef, open, on onOpenChange(open: boolean): void; }) { const description = definition?.description?.trim(); - return + const clicked = useRef(false); + return { + if (!next || !clicked.current) onOpenChange(next); + }}> { // Adjacent entries must switch immediately, even inside the previous // tooltip's pointer grace area used when moving into its readable card. - if (event.pointerType !== 'touch') onOpenChange(true); + 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) => { // pointerEnter owns hover opening. Skip Radix's second opening path so // moving inside an entry after clicking it cannot reopen its introduction. diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index 64fd5c54..5a0d72db 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -73,7 +73,7 @@ /* The teacher speaks from the title bar without taking the student's focus. */ .classroom-composer-view[hidden] { display: none; } -.teacher-companion { position: relative; min-width: 0; max-width: 65%; } +.teacher-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-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; } @@ -85,7 +85,10 @@ .teacher-introduction-arrow { fill: #ead6af; } @keyframes teacher-introduction-in { from { opacity: 0; } to { opacity: 1; } } @media (prefers-reduced-motion: reduce) { .teacher-introduction { animation: none; } } -.consultation-agent-tabs { display: flex; align-items: center; gap: 4px; max-width: 100%; overflow-x: auto; scrollbar-width: thin; } +.consultation-agent-tabs { 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; } .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; } diff --git a/src/pages/Chat/use-teacher-companion.ts b/src/pages/Chat/use-teacher-companion.ts index 1a118be2..4488965d 100644 --- a/src/pages/Chat/use-teacher-companion.ts +++ b/src/pages/Chat/use-teacher-companion.ts @@ -1,5 +1,5 @@ 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 { AppError } from '@/lib/error-model'; import { @@ -11,8 +11,12 @@ import { } from '../../../shared/coding-teacher'; interface CompanionState { + catalogState?: TeacherCatalogState; + refreshing: boolean; + refreshStatus: 'idle' | 'success' | 'error'; 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; enabled: 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 scope = `${account}:${options.projectId ?? ''}`; const storageKey = `makelore-teacher-presence:${scope}`; - const [state, setState] = useState({ agents: [], definition: null, enabled: false, loading: true, topic: null, seen: [], welcomeDismissed: false, error: '' }); + const [state, setState] = useState({ agents: [], definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen: [], welcomeDismissed: false, error: '' }); const selectionEpoch = useRef(0); const latest = useRef({ options, state }); latest.current = { options, state }; @@ -55,6 +59,32 @@ export function useTeacherCompanion(options: Options) { scopeRef.current = scope; const stateScope = useRef(scope); const inFlight = useRef(false); + const configLoader = useRef<(() => Promise<{ enabled: boolean }>) | null>(null); + const reloadCatalog = useCallback(async () => { await configLoader.current?.(); }, []); + const refreshFlight = useRef<{ load: NonNullable; promise: Promise } | null>(null); + const refresh = useCallback((): Promise => { + 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) => { if (scopeRef.current !== scope || topic.projectId !== options.projectId) return; setState((current) => current.topic?.id === topic.id && current.topic.revision >= topic.revision @@ -64,7 +94,7 @@ export function useTeacherCompanion(options: Options) { }, [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; + if (id === (current.selectedAgent?.teacher_id ?? current.topic?.definition.config_id)) return; const agent = current.agents.find((item) => item.teacher_id === id); if (!agent) return; selectionEpoch.current++; @@ -93,24 +123,38 @@ export function useTeacherCompanion(options: Options) { const welcomeDismissed = readLocal(storageKey + ':welcome-dismissed', false) === true; 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; if (!projectId) { setState((current) => ({ ...current, loading: false })); return () => { alive = false; }; } const base = teacherTopicsPath(projectId); - const loadConfig = async () => { - const config = await teacherApi.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 }; + let configFlight: Promise<{ enabled: boolean }> | null = null; + const loadConfig = () => { + if (configFlight) return configFlight; + configFlight = (async () => { + const [config, catalog] = await Promise.all([teacherApi.config(), teacherApi.catalog()]); + if (!alive || scopeRef.current !== scope) return { enabled: false }; + const select = (current: CompanionState) => { + const id = current.selectedAgent?.teacher_id ?? current.topic?.definition.config_id; + return catalog.items.find((item) => item.teacher_id === id) + ?? (!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 () => { try { await loadConfig(); @@ -183,6 +227,7 @@ export function useTeacherCompanion(options: Options) { document.addEventListener('visibilitychange', onVisible); return () => { alive = false; + if (configLoader.current === loadConfig) configLoader.current = null; window.clearInterval(timer); window.removeEventListener('focus', onVisible); document.removeEventListener('visibilitychange', onVisible); @@ -211,12 +256,12 @@ export function useTeacherCompanion(options: Options) { const last = topic?.requests.at(-1); 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. 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 selectedAgent = visibleState.selectedAgent === null ? undefined + : 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 { @@ -224,6 +269,8 @@ export function useTeacherCompanion(options: Options) { error: last?.intent === 'check-in' && ['failed', 'interrupted'].includes(last.status) ? '智能体这次没连上,可以点头像找智能体聊聊。' : visibleState.error, definition: selectedDefinition, selectedAgent, + refresh, + reloadCatalog, selectAgent, topic, invitation, diff --git a/tests/e2e/fixtures/teacher-preview-layout.tsx b/tests/e2e/fixtures/teacher-preview-layout.tsx index 011500d6..eac55f94 100644 --- a/tests/e2e/fixtures/teacher-preview-layout.tsx +++ b/tests/e2e/fixtures/teacher-preview-layout.tsx @@ -14,7 +14,9 @@ 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 [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 agents = Array.from({ length: count }, (_, index) => ({ 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' }, invitation: visible ? { response: long ? '你刚才说想做一个宠物游戏。我们一起想想,什么样的小动作,会让你愿意一直陪它玩下去?可以先挑一个最想试的想法。' : '有什么编程问题想一起讨论?' } : 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), } as ComponentProps['companion']; return
diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index 36f1a699..574e68a0 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -4,6 +4,7 @@ import { createServer } from 'node:http'; import { createRequire } from 'node:module'; import { resolve } from 'node:path'; import type { NativeAgentBrowserFixture } from './fixtures/native-agent-browser'; +import type { TeacherCatalog, TeacherDefinition } from '../../shared/coding-teacher'; import { expect, getStableWindow, test } from './fixtures/electron'; type CapturedRequest = { @@ -20,6 +21,13 @@ type HostConnection = { token: string; }; +type TeacherCatalogFixture = { + teacherCatalog: TeacherCatalog; + teacherCatalogFailure: boolean; + holdTeacherCatalog: boolean; + releaseTeacherCatalog: (() => void) | null; +}; + async function disableCodingEventSource(page: Page): Promise { await page.addInitScript(() => { const sources = new Set(); @@ -92,7 +100,7 @@ async function installCodingFirstChatHost( await electronApp.evaluate(async (_, payload) => { const { connection, featureComplete, managedCapabilities, removedModel, audioPreview } = payload; const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); - type MainState = { + type MainState = TeacherCatalogFixture & { captured: CapturedRequest[]; conversationCreated: boolean; interactionAnswered: boolean; @@ -116,6 +124,10 @@ async function installCodingFirstChatHost( releaseSnapshot: null, snapshotPending: false, snapshotSettled: false, + teacherCatalog: { items: [] }, + teacherCatalogFailure: false, + holdTeacherCatalog: false, + releaseTeacherCatalog: null, }; mainGlobal.__makelorePiFirstChatE2E = state; const now = '2026-08-24T00:00:00.000Z'; @@ -454,8 +466,8 @@ async function installCodingFirstChatHost( ...overrides, }); - const teacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'一起发现代码里的小奥秘,把想法变成作品。',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}}; - const teachers = [ + 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}}; + 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-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') { - 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:'你刚才想加排行榜,我们一起想想比什么更有意思?'}]}; 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((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}); const consultationPath = path.match(/\/(agent|teacher)-topics(?:\/|$)/)?.[1]; if (consultationPath) { @@ -546,7 +567,7 @@ async function installCodingFirstChatHost( 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 === '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:[]}; 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 }) => { const electronApp = await launchElectronApp({ skipSetup: true }); let page = await getStableWindow(electronApp); diff --git a/tests/e2e/teacher-preview-layout.spec.ts b/tests/e2e/teacher-preview-layout.spec.ts index ee7d4c1b..1cc8be0b 100644 --- a/tests/e2e/teacher-preview-layout.spec.ts +++ b/tests/e2e/teacher-preview-layout.spec.ts @@ -40,7 +40,10 @@ for (const width of [520, 1000]) { await page.setViewportSize({ width, height: 800 }); await page.goto(baseUrl + '?agents=8'); const tabs = page.getByRole('navigation', { name: '已下发智能体' }); + const refresh = page.getByRole('button', { name: '刷新智能体', exact: true }); 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); const last = tabs.getByRole('button', { name: '与智能体8聊天' }); await last.focus(); @@ -48,13 +51,22 @@ for (const width of [520, 1000]) { await expect(last).toHaveAttribute('aria-pressed', 'true'); const bounds = await page.evaluate(() => { const strip = document.querySelector('.consultation-agent-tabs')!; + const refresh = document.querySelector('button[aria-label="刷新智能体"]')!; const controls = document.querySelector('[data-testid="window-controls"]')!; return { right: strip.getBoundingClientRect().right, controlsLeft: controls.getBoundingClientRect().left, + refreshLeft: refresh.getBoundingClientRect().left, refreshRight: refresh.getBoundingClientRect().right, 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.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') }); }); } diff --git a/tests/unit/teacher-companion.test.tsx b/tests/unit/teacher-companion.test.tsx index e87ff0a3..0b6514f6 100644 --- a/tests/unit/teacher-companion.test.tsx +++ b/tests/unit/teacher-companion.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useTeacherCompanion } from '@/pages/Chat/use-teacher-companion'; 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'; +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() })); 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); else { if (id) companion.selectAgent(id); setOpen(true); } }} /> - {open && }; + {open && }; } async function mount(props: Parameters[0] = {}) { let view!: ReturnType; @@ -51,6 +51,160 @@ beforeEach(() => { }); 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((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((done) => { resolve = done; })); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' }))); + api.catalog.mockResolvedValue({ items: [current] }); + await act(async () => view.rerender()); + 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', () => { 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: '把大问题拆成小积木,一起试试看。' } };