fix: isolate teacher references from unrelated selections

This commit is contained in:
鲨鱼辣椒
2026-09-30 11:19:37 +08:00
parent c55e9c7182
commit 36202e0642
6 changed files with 235 additions and 11 deletions

View File

@@ -0,0 +1,52 @@
# Task: Isolate pending teacher message references
## Identity
- Task ID: 20260930-teacher-reference-isolation-70b4a183
- Mode: Feature
- Branch: codex/20260930-teacher-reference-isolation-70b4a183-teacher-reference-isolation
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260930-teacher-reference-isolation-70b4a183
- Base commit: c55e9c718201eb8986a83e545e9b3ad7b110dd7e
- Owner: codex
- Status: Ready for Integration
## Scope
- Diagnose and fix unintended quotation of text from another teacher when opening/switching the teacher consultation panel.
## Intent And Constraints
- Preserve history, existing saved drafts, cloud teacher configuration, operation messages, billing and send identity. No subagents, live model calls, main merge/push, cloud publication or app restart. Never remove persisted references by guessing their origin.
## Outcome
- Confirmed the screenshot mechanism: openTeacher captured the global DOM selection, including another teacher's message, then stored it in the newly selected teacher's draft. Ordinary typed sends include draft references; configured shortcuts intentionally send none. Also reproduced consumed quote resurrection after reopening the dock.
- Restricted new capture to ranges entirely inside the currently visible operation conversation; bound one-time quote handoff to account/project/source/target teacher and consume it after acceptance. Existing teacher drafts remain independent. Clear/send no longer revives the original prop on reopen. Cloud configurations, history and saved drafts are untouched.
## Verification
- Added two integrated CodingChatPanel regressions using real DOM Range/Selection; both failed against the original code and pass after the fix. They cover cross-teacher capture, independent outgoing references, valid operation quote, switching recipients, removal/reopen and send/reopen.
- 125 relevant unit tests passed across four files. Added and passed one Electron production-Renderer scenario confirming selection/click behavior and actual captured Host API request references. E2E used a temporary generated-entry focus guard; restored original compiled bytes afterward. No live app/model calls.
- Typecheck, full lint (0 errors / 8 existing warnings), final scoped lint, production build and whitespace checks passed. Logs /tmp/teacher-reference-{repro,focused,unit,typecheck,lint,build,electron,scoped-lint}.log. Task-aware docs check passed.
## Follow-ups
- Fix is on this feature branch, not merged/pushed or installed into the running trial. Existing persisted misquoted draft references are not automatically deleted because they lack reliable source metadata; the user can remove the visible item explicitly. No historical message rewrite or cloud deployment.
## Promotion Candidates
- Target: canonical consultation draft/reference boundaries. New selection capture must originate wholly in the visible operation conversation and be a one-time handoff to an account/project/source/teacher recipient. Evidence: reproducible cross-teacher failure and 125 unit plus one Electron regression pass. This enforces existing chat isolation without changing teacher permissions; canonical promotion remains deferred.
## Project Context Loaded
- Exact feature owner/worktree/branch/base c55e9c7 verified with official start/status. App worktree API unavailable in parent non-Git workspace; official isolation succeeded. Reused unchanged entry/memory/positioning/system/decision/domain/success context from the preceding merge; reread active record, teacher ADR and data-flow reference boundaries, evidence and commitments indexes. Latest main is clean and equal to fetched origin/main.
- Read all peer task records, assessing Scope/Intent/Promotion. Remaining active release/help-label tasks do not modify quotation capture; undefined child-language scope has no concrete dependency. No canonical writes.
- Source: CodingChatPanel.openTeacher reads the whole window selection, without source validation. TeacherChatPanel persists incoming quote in the selected agent draft; normal free-input sends include references, configured shortcuts deliberately send an empty list. Existing per-account/project/teacher draft isolation works, but the global selection can import another teacher's text. Parent quote also lacks recipient scope and one-time consumption.
- Existing policy: teachers have separate project chats/drafts, explicit references retain operation-project/source identity and must not cross source boundaries. Retain legitimate operation-chat selection quoting and saved data.
- Gate: Passed.
## Plan
1. Reproduce selection from teacher A entering teacher B using real DOM selection in the existing CodingChatPanel test.
2. Restrict capture to the visible operation conversation, bind a new quote to account/project/source/recipient, and consume the one-time quote after the panel accepts it so removal/sending does not resurrect it on reopen.
3. Cover independent saved drafts, switch/remove/reopen and actual send references; run relevant units, typecheck/lint/build and available browser integration. Report cause and distinguish code fix from existing stored draft cleanup.

View File

@@ -189,6 +189,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
- 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。
- 输入框为空时,在输入框外上方显示当前老师已发布的快捷提示;采用 13px 半粗字重,每条独立一行、按顺序使用不同的彩色文字和同色轻微文字阴影,搭配小箭头;无底色、无边框、无块状阴影,入口距输入框 8px;悬停时文字加深并显示下划线,键盘聚焦有清晰轮廓,保留 30px 点击高度,长文案自动换行。按 Yuxi 中配置的顺序展示文案,点击直接发送该项实际提示词。Yuxi“编辑 → 快捷提示”支持最多 8 项,每项文案最多 40 字、提示词最多 6000 字;保存并发布后,目录更新即可生效,历史和未发送草稿保留。明确发布空列表隐藏全部按钮;缺少字段的历史版本保留“帮我整体看🧠”。多个长文案在限定高度内滚动,运行中禁止重复发送。上线需先更新 WS 登记接口,再更新 Yuxi 和客户端。
- `reply.ts` 只声明正文与快捷回复的输出格式,语言、长度、前缀与表达风格由所选智能体的云端配置决定。客户端不再发送选择、生成、转换或维护展示组件的指令。旧话题内的组件状态、快照和解析诊断作为不透明历史数据原样保留,不校验删除、不恢复交互;旧组件修改路由已移除。
- 打开老师时,仅接纳当前可见操作对话中选中的文字作为新引用;选中其他老师聊天或页面其他区域的文字不会自动带入。新引用绑定当前账号、项目、操作来源与目标老师,并在接纳后消费一次;移除或发送后,重开面板不恢复这次临时引用。已有老师草稿和历史保持,不能凭内容猜测来源并自动删除。
- 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用、咨询正文和快捷回复,并支持上述只读原文恢复;旧组件状态、快照和未解析原文不加入模型上下文或咨询历史读取结果。没有跨全部操作会话自动读取,也未向操作 Agent 注入咨询共识。提示词不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地智能体由 Main 调用计费网关;均不创建本地 Pi 编程 worker。
- 智能体输入裁剪区分两种计量:本地模型以约每 2 个 UTF-8 字节估算 1 Token,并计入工具与读取预留;这不是精确分词或扣费依据,模型上限与实际 usage 仍由服务端拥有。云端沿用 query 字节上限,以完整 JSON 转义后的大小裁剪来源节选。智能体指令、当前问题和明确引用不会被静默裁掉;固定配置或回复协议超限时明确提示调整配置,避免让学生反复缩短短问题或新建话题。完整来源仍可由只读工具按需读取。
- `tests/fixtures/teacher-guidance-examples.json` 保留人工样例作为旧回复的读取兼容资料,仅验证正文和快捷回复投影,不加载到运行时,也不代表真实模型教学效果已验收。云端定义由 Yuxi 维护并由运营显式同步下发;本地代码修改不会自动改写云端配置或学生已接受问题的固定发布版本。

View File

@@ -191,8 +191,13 @@ export function CodingChatPanel({
const [teacherComposing, setTeacherComposing] = useState(false);
const [consultationWidth, setConsultationWidth] = useState(508);
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
const [teacherQuote, setTeacherQuote] = useState<TeacherReference | undefined>();
const [teacherQuoteSource, setTeacherQuoteSource] = useState<string | null>(null);
const operationChatRef = useRef<HTMLDivElement>(null);
const [teacherQuote, setTeacherQuote] = useState<{
reference: TeacherReference; account: string; projectId: string; sourceId: string; agentId: string;
}>();
const consumeTeacherQuote = useCallback((reference: TeacherReference) => {
setTeacherQuote(current => current?.reference === reference ? undefined : current);
}, []);
const appliedNavigationDraftRef = useRef<string | null>(null);
const selectedConversationContextRef = useRef<string | null>(null);
const attachmentsRef = useRef(attachmentsByDraftKey);
@@ -251,9 +256,18 @@ export function CodingChatPanel({
const [teacherIntroduction, setTeacherIntroduction] = useState<HTMLDivElement | null>(null);
const openTeacher = (agentId?: string) => {
if (agentId) teacherCompanion.selectAgent(agentId);
const selectedText = window.getSelection()?.toString().trim();
setTeacherQuote(selectedText ? { kind: 'code', text: selectedText.slice(0, 12000) } : undefined);
setTeacherQuoteSource(`${activeProject?.id}:${targetConversationId}`);
const selection = window.getSelection();
const operationChat = operationChatRef.current;
// Avatar navigation must not turn selected text in another teacher's chat into a quote.
const fromOperationChat = operationChat && !operationChat.hidden && selection?.rangeCount
&& Array.from({ length: selection.rangeCount }, (_, index) => selection.getRangeAt(index))
.every(range => operationChat.contains(range.commonAncestorContainer));
const selectedText = fromOperationChat ? selection.toString().trim() : '';
setTeacherQuote(selectedText && activeProject && targetConversationId ? {
reference: { kind: 'code', text: selectedText.slice(0, 12000) }, account,
projectId: activeProject.id, sourceId: targetConversationId,
agentId: agentId ?? teacherCompanion.selectedAgent?.teacher_id ?? 'legacy',
} : undefined);
setConsultationOpen(true);
setAgentBrowserOpen(true);
};
@@ -313,7 +327,7 @@ export function CodingChatPanel({
useEffect(() => {
setAgentBrowserOpen(false);
}, [activeProject?.id]);
useEffect(() => { setTeacherQuote(undefined); }, [activeProject?.id, targetConversationId]);
useEffect(() => { setTeacherQuote(undefined); }, [account, activeProject?.id, targetConversationId]);
useEffect(() => () => disconnectEvents(), [disconnectEvents]);
@@ -812,7 +826,7 @@ export function CodingChatPanel({
}} />
</div>
<CodingChatBody welcome={showWelcome} hidden={agentBrowserOpen} bubble={teacherBubble}>
<div id="classroom-chat-view" role="tabpanel" aria-labelledby="classroom-chat-tab" hidden={agentBrowserOpen} className="classroom-chat-view min-h-0 flex-1 flex-col">
<div ref={operationChatRef} id="classroom-chat-view" role="tabpanel" aria-labelledby="classroom-chat-tab" hidden={agentBrowserOpen} className="classroom-chat-view min-h-0 flex-1 flex-col">
{(workspaceError || conversationMetadataError || connectionError) && (
<div className="mx-4 mt-3 flex min-h-10 items-center gap-2 rounded-xl bg-destructive/5 px-3 py-2 text-xs text-destructive sm:mx-5">
@@ -984,7 +998,10 @@ export function CodingChatPanel({
externalTopic={teacherCompanion.topic}
onTopicChange={teacherCompanion.noteTopic}
onComposingChange={setTeacherComposing}
quote={consultationOpen && teacherQuoteSource === `${activeProject.id}:${targetConversationId}` ? teacherQuote : undefined} />
quote={consultationOpen && teacherQuote?.account === account && teacherQuote.projectId === activeProject.id
&& teacherQuote.sourceId === targetConversationId
&& teacherQuote.agentId === (teacherCompanion.selectedAgent?.teacher_id ?? 'legacy') ? teacherQuote.reference : undefined}
onQuoteConsumed={consumeTeacherQuote} />
</div>
)}
</section>

View File

@@ -43,6 +43,7 @@ function ProjectTeacherChatPanel({
sourceId,
onBringBack,
quote,
onQuoteConsumed,
draftRevision,
sampleContext,
externalTopic,
@@ -56,6 +57,7 @@ function ProjectTeacherChatPanel({
sourceId: string;
onBringBack?(text: string): void;
quote?: TeacherReference;
onQuoteConsumed?(reference: TeacherReference): void;
draftRevision?: number;
sampleContext?: string;
projectName?: string;
@@ -84,7 +86,7 @@ function ProjectTeacherChatPanel({
return selectedAgent ? JSON.parse(localStorage.getItem(`makelore-consultation-draft:${account}:${projectId}:agent:${selectedAgent.teacher_id}`) ?? '{}') : {};
} catch { return {}; }
});
const [referenceScope, setReferenceScope] = useState({ projectId: saved.projectId ?? projectId, sourceId: saved.sourceId ?? sourceId });
const [referenceScope, setReferenceScope] = useState(quote ? { projectId, sourceId } : { projectId: saved.projectId ?? projectId, sourceId: saved.sourceId ?? sourceId });
const referenceScopeRef = useRef(referenceScope);
referenceScopeRef.current = referenceScope;
const [legacyDraft] = useState<{ text?: string; references?: TeacherReference[] }>(() => {
@@ -211,8 +213,12 @@ function ProjectTeacherChatPanel({
}
}, [busy]);
useEffect(() => {
if (quote) { setReferences([quote]); setReferenceScope({ projectId, sourceId }); }
}, [quote, projectId, sourceId]);
if (quote) {
setReferences([quote]);
setReferenceScope({ projectId, sourceId });
onQuoteConsumed?.(quote);
}
}, [quote, projectId, sourceId, onQuoteConsumed]);
useEffect(() => {
let alive = true;
setBusy(true);

View File

@@ -1855,6 +1855,52 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
});
test('teacher navigation isolates selected text and consumes operation quotes once', 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 page.getByRole('button', { name: '与朋友聊天', exact: true }).click();
const panel = page.getByTestId('teacher-chat-panel');
const input = panel.getByRole('textbox', { name: '向智能体提问' });
await input.fill('只有测试老师的提问');
await panel.getByRole('button', { name: '提问', exact: true }).click();
await expect(panel.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await panel.getByText('只有测试老师的提问', { exact: true }).evaluate(element => {
const range = document.createRange(); range.selectNodeContents(element);
window.getSelection()!.removeAllRanges(); window.getSelection()!.addRange(range);
});
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
await expect(panel.getByRole('button', { name: '移除引用' })).toHaveCount(0);
await input.fill('正式老师的独立提问');
await panel.getByRole('button', { name: '提问', exact: true }).click();
await expect(panel.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
const sent = (await readState(electronApp)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST');
expect(sent).toHaveLength(2);
expect(sent[1]).toMatchObject({ path: expect.stringContaining('/teacher-code/messages'), body: { text: '正式老师的独立提问', references: [] } });
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
const operationMessage = page.getByTestId('coding-conversation-timeline').locator('[data-node-kind="message"]').first();
await expect(operationMessage).toBeVisible();
await operationMessage.evaluate(element => {
const range = document.createRange(); range.selectNodeContents(element);
window.getSelection()!.removeAllRanges(); window.getSelection()!.addRange(range);
});
await page.getByRole('button', { name: '与朋友聊天', exact: true }).click();
await panel.getByRole('button', { name: '移除引用' }).click();
await page.getByRole('button', { name: '收起老师对话栏' }).click();
await page.getByRole('button', { name: '展开老师对话栏' }).click();
await expect(input).toBeEnabled();
await expect(panel.getByRole('button', { name: '移除引用' })).toHaveCount(0);
expect((await readState(electronApp)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST')).toHaveLength(2);
} finally { await releaseSnapshot(electronApp); }
});
test('project consultations preserve student drafts and switch between work and chat without submitting advice', 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}));

View File

@@ -321,6 +321,108 @@ describe('CodingChatPanel first Conversation', () => {
expect(conversationApi.submit).not.toHaveBeenCalled();
});
async function renderQuotationWorkspace() {
localStorage.clear();
window.getSelection()?.removeAllRanges();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(withForkSource(createLocalConversationSnapshot(project.id, conversation), 'quote-source'));
const definitions: TeacherDefinition[] = ['测试Alice对比', '产品工程师-Alice'].map((name, index) => ({
schema_version: 1, config_id: `quote-agent-${index}`, teacher_id: 'teacher', name,
description: '', avatar_id: 'avatar-01', welcome_message: '聊聊吧', suggested_questions: [],
system_prompt: '', skills: [], model: { model_id: 'model', reasoning_choice: { mode: 'default' } },
limits: { max_input_tokens: 8000, max_output_tokens: 1000 },
}));
const topics = definitions.map((definition, index): TeacherTopic => ({
schemaVersion: 1, revision: 1, id: `quote-history-${index}`, accountId: 'signed-out',
projectId: project.id, sourceConversationId: conversation.id, definition, version: 1,
createdAt: '2026-09-30T00:00:00.000Z', updatedAt: '2026-09-30T00:00:00.000Z',
requests: [{ id: `quote-reply-${index}`, text: '看看项目', references: [], createdAt: '2026-09-30T00:00:00.000Z',
sourceCursor: { seq: 0, workerGeneration: 0 }, sourceCapturedAt: '2026-09-30T00:00:00.000Z',
includedSourceMessageIds: [], omittedMessages: 0, status: 'completed',
response: index ? '这是产品老师的回复' : '只有测试老师里的内容',
projectId: project.id, sourceConversationId: conversation.id, teacherVersion: 1 }],
conversation: { agentId: definition.config_id!, segmentTurns: 1, discussions: {}, seenThrough: `quote-reply-${index}` },
}));
const { teacherApi } = await import('@/lib/coding-teacher');
vi.spyOn(teacherApi, 'config').mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition: definitions[0] });
vi.spyOn(teacherApi, 'catalog').mockResolvedValue({ items: definitions.map((definition, index) => ({ teacher_id: definition.config_id!, version: 1, definition, is_default: index === 0 })) });
vi.spyOn(teacherApi, 'conversation').mockImplementation(async (_projectId, id) => ({ topic: topics.find(topic => topic.definition.config_id === id)!, before: null }));
vi.spyOn(teacherApi, 'events').mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const send = vi.spyOn(teacherApi, 'sendConversation').mockImplementation(async (_projectId, id, input) => {
const topic = topics.find(topic => topic.definition.config_id === id)!;
return { ...topic, revision: 2, requests: [...topic.requests, { ...topic.requests[0], id: input.requestId, text: input.text, references: input.references ?? [], response: '这次提问已收到' }] };
});
const browserApi = await import('@/lib/agent-browser');
const browser = { browserId: null, projectId: project.id, projectPath: null, state: 'closed' as const, generation: 0,
url: '', title: '', visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0 };
vi.spyOn(browserApi, 'getAgentBrowserState').mockResolvedValue(browser);
vi.spyOn(browserApi, 'closeAgentBrowser').mockResolvedValue(browser);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
render(<CodingChatPanel />);
await screen.findByRole('button', { name: '与测试Alice对比聊天' });
await screen.findByText('Fork source');
return { send };
}
function selectText(element: Element) {
const range = document.createRange();
range.selectNodeContents(element);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
}
it('does not capture one teacher message into another teacher draft or request', async () => {
const { send } = await renderQuotationWorkspace();
fireEvent.click(screen.getByRole('button', { name: '与测试Alice对比聊天' }));
selectText(await screen.findByText('只有测试老师里的内容'));
fireEvent.click(screen.getByRole('button', { name: '与产品工程师-Alice聊天' }));
await screen.findByText('这是产品老师的回复');
const input = screen.getByRole('textbox', { name: '向智能体提问' });
await waitFor(() => expect(input).toBeEnabled());
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(`makelore-consultation-draft:signed-out:project:${project.id}:agent:quote-agent-1`)!)).toMatchObject({ references: [] });
fireEvent.change(input, { target: { value: '产品老师帮我看看' } });
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await waitFor(() => expect(send).toHaveBeenCalledWith(project.id, 'quote-agent-1', expect.objectContaining({ text: '产品老师帮我看看', references: [] })));
});
it('keeps an operation quote with its recipient and does not resurrect it after removal or sending', async () => {
const { send } = await renderQuotationWorkspace();
selectText(screen.getByText('Fork source'));
fireEvent.click(screen.getByRole('button', { name: '与测试Alice对比聊天' }));
await waitFor(() => expect(screen.getByRole('textbox', { name: '向智能体提问' })).toBeEnabled());
expect(screen.getByRole('button', { name: '移除引用' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '与产品工程师-Alice聊天' }));
await screen.findByText('这是产品老师的回复');
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '与测试Alice对比聊天' }));
await screen.findByText('只有测试老师里的内容');
fireEvent.click(screen.getByRole('button', { name: '移除引用' }));
fireEvent.click(screen.getByRole('button', { name: '收起老师对话栏' }));
fireEvent.click(screen.getByRole('button', { name: '展开老师对话栏' }));
await waitFor(() => expect(screen.getByRole('textbox', { name: '向智能体提问' })).toBeEnabled());
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: '操作对话' }));
selectText(screen.getByText('Fork source'));
fireEvent.click(screen.getByRole('button', { name: '与产品工程师-Alice聊天' }));
const input = screen.getByRole('textbox', { name: '向智能体提问' });
await waitFor(() => expect(input).toBeEnabled());
fireEvent.change(input, { target: { value: '解释这段操作内容' } });
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await waitFor(() => expect(send).toHaveBeenCalledWith(project.id, 'quote-agent-1', expect.objectContaining({ references: [{ kind: 'code', text: 'Fork source' }] })));
await screen.findByText('这次提问已收到');
fireEvent.click(screen.getByRole('button', { name: '收起老师对话栏' }));
fireEvent.click(screen.getByRole('button', { name: '展开老师对话栏' }));
await waitFor(() => expect(screen.getByRole('textbox', { name: '向智能体提问' })).toBeEnabled());
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
expect(send).toHaveBeenCalledTimes(1);
window.getSelection()?.removeAllRanges();
});
it('toggles the teacher dock from the header without changing tabs, drafts, history, or sending prompts', async () => {
localStorage.clear();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });