fix: keep first work preview full height during teacher welcomes

This commit is contained in:
鲨鱼辣椒 committed 2026-10-08 12:44:04 +08:00
1 parent fe1a3775c5
commit d12d214d7f
8 files changed
+187 -34

No files matched your search

@@ -0,0 +1,74 @@
# Task: Investigate and fix the first-open work preview layout
## Identity
- Task ID: 20261008-work-first-open-8ac7e621
- Mode: Feature
- Branch: codex/20261008-work-first-open-8ac7e621-work-first-open
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261008-work-first-open-8ac7e621
- Base commit: fe1a3775c5f785adaa6372bd7ea3102c79331656
- Owner: codex
- Status: Ready for Integration
## Scope
- Fix the first-open Work preview being pushed below the entire teacher welcome group. Renderer visibility only; preserve native-preview overlay clearance for real messages and hover introductions.
## Intent And Constraints
- User confirmed: temporarily hide welcome introductions on Work, restore on Operation Chat. Do not persist dismissal or change the three-operation-round introduction lifecycle, unread receipts, consultation entry, or preview startup.
- No running-app update, main merge, push, or cleanup requested for this change.
## Project Context Loaded
Task context:
- Task ID / mode / branch / worktree / base: exactly as Identity above; official start and status ownership verification passed.
- Other local tasks: 27 peer owner records inspected by read-only reviewer through their registered paths; no active conflicting layout work.
- Related completed scopes: teacher-group-intro, hover intro, preview toolbar, empty-work preview. Preserve grouped dismissal and real unread messages.
Read:
- Agent entry/read-before-planning, memory-index, planning-gate; this task record.
- Project positioning, current-state, decision-index, system-overview, business-rules, success-criteria (task-relevant sections).
- ADR-2026-09-22-coding-teacher; evidence-index, reflection-index, commitments; AGENTS.md and README.md.
Relevant understanding:
- Project goal: student creation workspace with Main-owned native Work preview and cloud-defined teachers.
- Integrated baseline: fe1a377, clean main. URL toolbar beside workspace tabs; native pixels avoid teacher DOM overlays.
- Root cause: bubbleRef points to the whole welcome group; usePreviewClearance offsets the whole panel below its bottom, wasting most of the first-open viewport.
- Constraints: only gate welcome rendering with the current tab; keep hook/persistence, real invitations, hover descriptions, and backend contracts unchanged.
- Canonical ADR still describes per-teacher introduction dismissal, superseded by the grouped implementation and README; this change preserves current grouped behavior.
- Evidence: user screenshots and static code; isolated browser measurements and regressions to follow. No applicable unresolved external commitment.
- Modules: TeacherCompanion, CodingChatPanel, related unit/browser/Electron fixtures and README.
Gate result: Passed.
## Plan
1. Add a presentation-only welcome visibility prop and derive it from the Work tab.
2. Cover hide/restore without consuming welcome state, real messages remaining visible, first-open preview geometry, and actual workspace integration.
3. Run focused unit/browser/Electron checks, typecheck, lint and production build; record evidence and hand off a committed feature branch.
## Outcome
- Added presentation-only `showWelcomes` to TeacherCompanion, controlled by `!agentBrowserOpen`. Both grouped introductions and the legacy static welcome are unmounted on Work, and return on Operation Chat if not already consumed by existing lifecycle rules.
- Real invitations/unread markers and hover descriptions remain visible with existing native-preview clearance. No persistent dismissal, model call, storage change, or preview startup change is triggered by this flag.
- Updated README, lifecycle unit regressions, four-size browser layout scenarios, and the existing actual Electron workspace test.
- Baseline reproduced in isolation: group height 440px; native preview was pushed down 391px, from top 107px to 498px. At 1280x800, height dropped from 639px to 248px; at 1440x900, from 739px to 348px. Fixed screenshots restore top 107px and full 639/739px height.
- Independent read-only review found no blocking issue, stale margin, ResizeObserver leak, or new first-open bounds race.
## Verification
- Typecheck, scoped ESLint, and production build:vite passed using pinned pnpm 10.33.4. Logs: `/tmp/makelore-first-work-{typecheck,lint,build}.log`.
- Focused units: 103 cases across teacher-companion, coding-chat-panel, and agent-browser-panel. Initial parallel run passed 102 and hit an existing asynchronous presentation assertion in unchanged agent-browser-panel; that file passed all 17 alone on retry. All changed-component tests passed initially. Logs: `/tmp/makelore-first-work-units.log`, `/tmp/makelore-first-work-browser-units-retry.log`.
- Browser layout suite: 18/18 passed, including 375/768/1280/1440px hide/restore, repeat visits, explicit dismissal, real messages, and hover clearance. Screenshots visually inspected; no horizontal overflow. `/tmp/work-first-open-browser-8ac7e621/run.log` and `results/`.
- Electron production-build fixture: 1/1 passed, proving actual workspace tab wiring, full-height preview, hover recovery, welcome restoration, teacher entry and retained drafts. `/tmp/makelore-first-work-electron.log`, `/tmp/makelore-first-work-electron-results/`.
- Baseline geometry and before/after images: `/tmp/work-first-open-repro-8ac7e621/`.
- Tests use isolated fixtures; installed app and user project/data were not changed. No installed-app update or native game-content acceptance claimed.
## Follow-ups
- Running-app update and main integration remain separate user-requested steps; feature branch is ready for handoff.
## Promotion Candidates
- Target: teacher ADR and system-overview. Proposal: static welcome introductions are paused on Work and restored on Operation Chat without consuming introduction or unread state; actual messages and hover descriptions retain native clearance. Evidence: user explicitly selected this behavior, focused lifecycle/browser/Electron regressions. Future impact: prevents reintroducing full-panel welcome clearance on first Work entry. Conflict: older ADR intro dismissal wording is stale relative to grouped implementation; leave it read-only in this feature. Further human confirmation for this visibility behavior is not required.
+1 -1
View File
@@ -177,7 +177,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
- 鼠标悬停或键盘聚焦顶部智能体入口时,以奶油色圆角卡片显示该智能体的发布介绍;离开、Escape 或点击入口后收起,没有介绍则不显示空卡片。咨询栏不再显示重复的头像、名称标题条,也不显示介绍及新话题、展开、关闭三个图标;老师身份保留在顶部入口及消息署名中。智能体生成的追加问题以淡蓝、奶油黄、淡紫卡片依次承载,正文 14px、行高 1.7,使用更紧凑的内边距和圆角,保留整卡点击发送与忙碌时禁用。回复下方不再显示重复的输入引导或省略早期消息条数;输入框提示为“和老师聊聊”。再次点击当前入口或在咨询栏按 Escape 可收起,切换其他入口仍直接打开对应讨论;拖动边缘调宽保留,已下发智能体仅保留只读旧记录入口。悬浮介绍不会创建话题、调用模型或确认未读消息,作品原生预览同时避让介绍卡和真实主动气泡。
- 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。
- 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。
- 进入项目时,每位已下发智能体各显示一个浅色自我介绍气泡,外层透明,不显示公共底板、标题或整组关闭入口;整张气泡可点击,每张气泡保留关闭按钮,关闭任意一张会收起整组介绍,优先使用云端介绍,缺省使用发布欢迎语;不调用模型、不声称已检查项目。所有气泡进入项目即作为一组显示,不点击时保留到操作 Agent 第 3 轮完整回复结束后自动收起,也可整组关闭;按账号、项目保存统一计数及整组已介绍状态,刷新与操作对话切换延续进度。旧历史、老师咨询、工具调用、重试中间状态、失败/中止和其他项目不计入,新增智能体加入仍在展示的整组,不会单独重开已收起的介绍。点击气泡或头像进入对应咨询,同时记住整组已介绍;关闭咨询、刷新或重新进入项目后整组仍收起,悬停头像仍可查看完整介绍。旧版任一老师已介绍时兼容为整组已介绍,否则延续已有操作轮次;旧记录仅兼容读取,不清空历史。没有下发目录的旧入口保留原欢迎语展示兼容。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。
- 进入项目时,每位已下发智能体各显示一个浅色自我介绍气泡,外层透明,不显示公共底板、标题或整组关闭入口;整张气泡可点击,每张气泡保留关闭按钮,关闭任意一张会收起整组介绍,优先使用云端介绍,缺省使用发布欢迎语;不调用模型、不声称已检查项目。所有气泡进入项目即作为一组显示;切到“作品”时暂时隐藏欢迎介绍,让作品使用完整预览空间,切回“操作对话”后恢复尚未收起的介绍,不标记已介绍或已读;真实主动消息和头像悬停介绍仍按原有方式显示并避让。不点击时保留到操作 Agent 第 3 轮完整回复结束后自动收起,也可整组关闭;按账号、项目保存统一计数及整组已介绍状态,刷新与操作对话切换延续进度。旧历史、老师咨询、工具调用、重试中间状态、失败/中止和其他项目不计入,新增智能体加入仍在展示的整组,不会单独重开已收起的介绍。点击气泡或头像进入对应咨询,同时记住整组已介绍;关闭咨询、刷新或重新进入项目后整组仍收起,悬停头像仍可查看完整介绍。旧版任一老师已介绍时兼容为整组已介绍,否则延续已有操作轮次;旧记录仅兼容读取,不清空历史。没有下发目录的旧入口保留原欢迎语展示兼容。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。
- 智能体支持自由提问;输入框为空时,上方显示老师已发布的快捷提示,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,程序说明学生主动求助且尚未提出具体问题,智能体结合可用上下文按云端配置回应。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接;失败或中断后,可点暖黄色小按钮“帮我整体看🧠”重试。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。
- 老师咨询展示流式思考过程、老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。
- 客户端只提供入口事实、可用上下文和界面协议。讨论切入点、教学方式、是否提问、正文长度、前缀和表达风格由所选智能体的云端配置决定;不在快捷求助、引导开口或后台检查时附加教学策略。当前新回复允许 0–3 条快捷回复,没有时用空数组,不要求凑满。
+1 -1
View File
@@ -794,7 +794,7 @@ export function CodingChatPanel({
onToggleConsultation={() => setConsultationOpen((current) => !current)}
projectName={activeProject?.name}
consultationOpen={consultationOpen}
teacherEntry={activeProject ? <TeacherCompanion companion={teacherCompanion} open={consultationOpen} onOpen={openTeacher} onToggle={toggleTeacher} bubbleRef={setTeacherBubble} introductionRef={setTeacherIntroduction} /> : undefined}
teacherEntry={activeProject ? <TeacherCompanion companion={teacherCompanion} open={consultationOpen} onOpen={openTeacher} onToggle={toggleTeacher} bubbleRef={setTeacherBubble} introductionRef={setTeacherIntroduction} showWelcomes={!agentBrowserOpen} /> : undefined}
/>
<div className="classroom-workspace-toolbar" data-testid="classroom-workspace-toolbar">
+5 -3
View File
@@ -6,13 +6,15 @@ import { TeacherIntroduction } from './TeacherIntroduction';
import type { useTeacherCompanion } from './use-teacher-companion';
type Companion = ReturnType<typeof useTeacherCompanion>;
export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef, introductionRef }: {
export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef, introductionRef, showWelcomes = true }: {
companion: Companion;
open: boolean;
onOpen(agentId?: string): void;
onToggle?(agentId?: string): void;
bubbleRef?: Ref<HTMLDivElement>;
introductionRef?: Ref<HTMLDivElement>;
/** Temporarily hide introductions without consuming their persisted state. */
showWelcomes?: boolean;
}) {
const { definition, invitation, welcome, welcomes, loading, enabled, checking, error } = companion;
const [introductionId, setIntroductionId] = useState<string | null>(null);
@@ -60,7 +62,7 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef,
<RefreshCw className={`h-3.5 w-3.5 shrink-0 ${companion.refreshing ? 'animate-spin motion-reduce:animate-none' : ''}`} aria-hidden="true" />
</button>
<span className="sr-only" role="status" aria-live="polite">{refreshMessage}</span>
{!invitation && welcomes.length > 0 && <div ref={bubbleRef} className="teacher-welcome-presence" data-testid="teacher-welcomes" aria-label="认识这里的老师">
{showWelcomes && !invitation && welcomes.length > 0 && <div ref={bubbleRef} className="teacher-welcome-presence" data-testid="teacher-welcomes" aria-label="认识这里的老师">
<div className="teacher-welcome-list">
{welcomes.map((item, index) => <article key={item.agentId} className="teacher-welcome-card" data-agent-id={item.agentId}
data-tone={item.tone} style={{ '--welcome-delay': `${Math.min(index, 4) * 180}ms` } as CSSProperties}>
@@ -80,7 +82,7 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef,
</article>)}
</div>
</div>}
{(invitation || welcome) && <div ref={bubbleRef} className="teacher-companion-presence" data-testid="teacher-presence">
{(invitation || (showWelcomes && welcome)) && <div ref={bubbleRef} className="teacher-companion-presence" data-testid="teacher-presence">
<div className="teacher-companion-bubble" data-testid="teacher-invitation" data-bubble-kind={invitation ? 'check-in' : 'welcome'}>
<div className="teacher-companion-bubble-heading">
<div className="teacher-companion-invitation-label">{name}</div>
@@ -12,7 +12,7 @@ export function Layout() {
const [bubble, setBubble] = useState<HTMLDivElement | null>(null);
const [introduction, setIntroduction] = useState<HTMLDivElement | null>(null);
const [toolbarContainer, setToolbarContainer] = useState<HTMLDivElement | null>(null);
const [workOpen, setWorkOpen] = useState(true);
const [workOpen, setWorkOpen] = useState(!new URLSearchParams(location.search).has('chat'));
const [visible, setVisible] = useState(!new URLSearchParams(location.search).has('quiet'));
const [open, setOpen] = useState(false);
const [width, setWidth] = useState(508);
@@ -51,7 +51,7 @@ export function Layout() {
return <div className="classroom-workspace flex h-screen flex-col bg-white">
<header className="relative z-50 flex h-10 shrink-0 items-center justify-end border-b px-3" data-testid="coding-conversation-header">
<span className="mr-auto min-w-0 truncate text-xs">布局测试项目 › hello</span>
<TeacherCompanion companion={companion} open={open}
<TeacherCompanion companion={companion} open={open} showWelcomes={!workOpen}
onOpen={(id) => { if (id) setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(true); }}
onToggle={(id) => { if (!id || id === agents[selected]?.teacher_id) setOpen(!open); else { setSelected(agents.findIndex((item) => item.teacher_id === id)); setOpen(true); } }}
bubbleRef={setBubble} introductionRef={setIntroduction} />
+15 -15
View File
@@ -2490,6 +2490,16 @@ test('teacher stays in the top-right header without legacy check-ins or taking o
const composer = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
await composer.fill('我还在写自己的想法');
await page.getByRole('tab', { name: '作品', exact: true }).click();
await expect(companion.getByTestId('teacher-welcomes')).toHaveCount(0);
const workPane = page.getByRole('tabpanel', { name: '作品', exact: true });
const workPanel = page.getByTestId('agent-browser-panel');
const expectFullPreview = async () => {
await expect.poll(async () => (await workPanel.boundingBox())!.y - (await workPane.boundingBox())!.y).toBe(20);
const [pane, preview] = await Promise.all([workPane.boundingBox(), workPanel.boundingBox()]);
expect(preview!.height).toBeGreaterThanOrEqual(pane!.height - 41);
};
await expectFullPreview();
await page.screenshot({ path: test.info().outputPath('first-work-full-preview.png') });
await expect(teacherButton).toBeVisible();
await teacherButton.hover();
await page.clock.runFor(250);
@@ -2505,7 +2515,8 @@ test('teacher stays in the top-right header without legacy check-ins or taking o
expect(introductionViewportBounds!.y).toBeGreaterThanOrEqual(introductionBounds!.y + introductionBounds!.height);
await page.keyboard.press('Escape');
await expect(introduction).toHaveCount(0);
await expect(invitation).toBeVisible();
await expect(invitation).toHaveCount(0);
await expectFullPreview();
expect((await readState(electronApp)).captured.filter(item => /\/(messages|teacher-check-in)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
await composer.focus();
@@ -2532,21 +2543,10 @@ test('teacher stays in the top-right header without legacy check-ins or taking o
await expect(page.getByRole('tab', { name: '操作对话', exact: true })).toHaveAttribute('aria-selected', 'true');
await page.screenshot({ path: test.info().outputPath('teacher-published-welcome.png') });
await page.getByRole('tab', { name: '作品', exact: true }).click();
await expect(invitation).toBeInViewport({ ratio: 1 });
const [workInvitationBounds, browserViewportBounds] = await Promise.all([
presence.boundingBox(), page.getByTestId('agent-browser-viewport').boundingBox(),
]);
expect(workInvitationBounds).not.toBeNull();
expect(browserViewportBounds).not.toBeNull();
// The native view uses this rectangle and cannot be covered by a DOM z-index.
expect(browserViewportBounds!.y).toBeGreaterThanOrEqual(workInvitationBounds!.y + workInvitationBounds!.height);
const workPane = page.getByRole('tabpanel', { name: '作品', exact: true });
const workPanel = page.getByTestId('agent-browser-panel');
// Multiple introductions need their actual height, with only the native-view gap added.
const nativeGap = browserViewportBounds!.y - workInvitationBounds!.y - workInvitationBounds!.height;
expect(nativeGap).toBeGreaterThanOrEqual(8);
expect(nativeGap).toBeLessThanOrEqual(9);
await expect(presence).toHaveCount(0);
await expectFullPreview();
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
await expect(presence.locator('.teacher-welcome-card')).toHaveCount(2);
await expect(composer).toHaveValue('我还在写自己的想法');
await page.clock.fastForward(300_000);
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0);
+41 -10
View File
@@ -117,11 +117,15 @@ test('empty work shows a local hint without opening the teacher or polling', asy
await page.screenshot({ path: test.info().outputPath('empty-work-hint.png') });
});
for (const width of [375, 768, 1280]) {
test(`all teacher introductions fit at ${width}px and keep the native preview clear`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await page.goto(baseUrl + '?agents=3&welcomes');
for (const size of [{ width: 375, height: 800 }, { width: 768, height: 800 }, { width: 1280, height: 800 }, { width: 1440, height: 900 }]) {
test(`teacher introductions pause on Work and restore on chat at ${size.width}px`, async ({ page }) => {
await page.setViewportSize(size);
await page.goto(baseUrl + '?agents=3&welcomes&chat=1');
const tabs = page.getByRole('tablist', { name: '当前工作' });
const chatTab = tabs.getByRole('tab', { name: '操作对话' });
const workTab = tabs.getByRole('tab', { name: '作品', exact: true });
const group = page.getByTestId('teacher-welcomes');
await expect(chatTab).toHaveAttribute('aria-selected', 'true');
await expect(group.locator('.teacher-welcome-card')).toHaveCount(3);
await expect(group).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(group).toHaveCSS('box-shadow', 'none');
@@ -132,14 +136,41 @@ for (const width of [375, 768, 1280]) {
await expect(card).toBeInViewport({ ratio: 1 });
expect(await card.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
}
await expect.poll(async () => {
const [bubble, viewport] = await Promise.all([group.boundingBox(), page.getByTestId('agent-browser-viewport').boundingBox()]);
return !!bubble && !!viewport && viewport.y >= bubble.y + bubble.height;
}).toBe(true);
await page.screenshot({ path: test.info().outputPath(`teacher-introductions-${width}.png`) });
await page.screenshot({ path: test.info().outputPath(`teacher-introductions-${size.width}.png`) });
const expectFullPreview = async () => {
await expect(workTab).toHaveAttribute('aria-selected', 'true');
await expect(group).toHaveCount(0);
await expect(page.locator('#coding-consultation-dock')).toHaveCount(0);
await expect(page.getByTestId('agent-browser-panel')).toHaveCSS('margin-top', '0px');
await expect.poll(async () => {
const [work, viewport] = await Promise.all([
page.getByTestId('work-view').boundingBox(), page.getByTestId('agent-browser-viewport').boundingBox(),
]);
// The full native viewport keeps only the work view's 20px padding
// and the panel's 1px border at both ends, with no welcome clearance.
return !!work && !!viewport && Math.abs(viewport.y - work.y - 21) <= 1
&& Math.abs(work.y + work.height - viewport.y - viewport.height - 21) <= 1;
}).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
};
for (let visit = 0; visit < 2; visit += 1) {
await workTab.click();
await expectFullPreview();
if (visit === 0) await page.screenshot({ path: test.info().outputPath(`first-work-with-welcomes-paused-${size.width}.png`) });
await chatTab.click();
await expect(group.locator('.teacher-welcome-card')).toHaveCount(3);
await expect(group).toBeInViewport({ ratio: 1 });
}
await group.getByRole('button', { name: '收起全部老师介绍(智能体2)' }).click();
await expect(group).toHaveCount(0);
await expect(page.locator('#coding-consultation-dock')).toHaveCount(0);
await workTab.click();
await expectFullPreview();
await chatTab.click();
await expect(group).toHaveCount(0);
await page.reload();
await expect(group.locator('.teacher-welcome-card')).toHaveCount(3);
await group.getByRole('button', { name: '和智能体3聊聊' }).click({ position: { x: 8, y: 8 } });
@@ -153,7 +184,7 @@ for (const width of [375, 768, 1280]) {
test('many teacher introductions remain scrollable and honor reduced motion', async ({ page }) => {
await page.setViewportSize({ width: 520, height: 600 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(baseUrl + '?agents=8&welcomes');
await page.goto(baseUrl + '?agents=8&welcomes&chat=1');
const group = page.getByTestId('teacher-welcomes');
await expect(group).toBeInViewport({ ratio: 1 });
await expect(group.locator('.teacher-welcome-card')).toHaveCount(8);
+48 -2
View File
@@ -21,11 +21,11 @@ const definition: TeacherDefinition = {
function checkInTopic(projectId = 'project', id = 'check-1'): TeacherTopic {
return { schemaVersion: 1, revision: 2, id: 'topic-' + projectId, role: 'teacher', accountId: 'kid', projectId, sourceConversationId: 'project', version: 1, definition, createdAt: 'now', updatedAt: 'now', requests: [{ id, intent: 'check-in', text: '', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '你刚才想加排行榜,我们一起想想比什么更有意思?' }] };
}
function Harness({ projectId = 'project', sourceId = 'source', busy = false, archived = false }: { projectId?: string; sourceId?: string; busy?: boolean; archived?: boolean }) {
function Harness({ projectId = 'project', sourceId = 'source', busy = false, archived = false, showWelcomes = true }: { projectId?: string; sourceId?: string; busy?: boolean; archived?: boolean; showWelcomes?: boolean }) {
const [open, setOpen] = useState(false);
const [teacherComposing, setTeacherComposing] = useState(false);
const companion = useTeacherCompanion({ projectId, sourceId, sourceBusy: busy, sourceArchived: archived, teacherOpen: open, teacherComposing });
return <><TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) companion.selectAgent(id); setOpen(true); }}
return <><TeacherCompanion companion={companion} open={open} showWelcomes={showWelcomes} onOpen={(id) => { if (id) companion.selectAgent(id); setOpen(true); }}
onToggle={(id) => {
if (open && (!id || id === companion.selectedAgent?.teacher_id)) setOpen(false);
else { if (id) companion.selectAgent(id); setOpen(true); }
@@ -75,6 +75,26 @@ describe('grouped teacher introductions', () => {
}));
}
it('temporarily hides introductions without consuming them and keeps counting completed operation rounds', async () => {
api.catalog.mockResolvedValue({ items: agents });
const view = await mount();
await operation('one');
await operation('two');
const saved = { ...localStorage };
await act(async () => view.rerender(<Harness showWelcomes={false} />));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '与方法老师聊天' })).toBeVisible();
expect({ ...localStorage }).toEqual(saved);
await act(async () => view.rerender(<Harness />));
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3);
await act(async () => view.rerender(<Harness showWelcomes={false} />));
await operation('three');
await act(async () => view.rerender(<Harness />));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
expect(api.sendConversation).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('leaves introductions until the third completed operation round and preserves hover and unread receipts', async () => {
api.catalog.mockResolvedValue({ items: agents });
localStorage.setItem('makelore-teacher-presence:kid:seen', JSON.stringify(['keep-seen']));
@@ -715,6 +735,32 @@ describe('teacher presence and periodic guidance', () => {
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
});
it('temporarily hides the legacy welcome without marking it dismissed', async () => {
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '欢迎一起创作' } });
const view = await mount();
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('欢迎一起创作');
const saved = { ...localStorage };
await act(async () => view.rerender(<Harness showWelcomes={false} />));
expect(screen.queryByTestId('teacher-presence')).not.toBeInTheDocument();
expect({ ...localStorage }).toEqual(saved);
await act(async () => view.rerender(<Harness />));
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('欢迎一起创作');
});
it('keeps real unread replies visible while welcomes are temporarily hidden', async () => {
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '欢迎一起创作' } });
api.list.mockResolvedValue({ items: [{ id: 'topic-project' }], lastSelectedTopicId: 'topic-project' });
api.read.mockResolvedValue(checkInTopic());
const view = await mount();
const saved = { ...localStorage };
await act(async () => view.rerender(<Harness showWelcomes={false} />));
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'check-in');
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
expect(view.container.querySelector('.teacher-companion-unread')).toBeInTheDocument();
expect({ ...localStorage }).toEqual(saved);
expect(api.seenConversation).not.toHaveBeenCalled();
});
it('gives a real unread reply priority over the greeting and does not show the greeting after acknowledgment', async () => {
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '欢迎一起创作' } });
api.list.mockResolvedValue({ items: [{ id: 'topic-project' }], lastSelectedTopicId: 'topic-project' });