diff --git a/.project-docs/30-worklog/tasks/20260928-teacher-reply-cleanup-c8e4a219.md b/.project-docs/30-worklog/tasks/20260928-teacher-reply-cleanup-c8e4a219.md index ec3901c1..f9550a52 100644 --- a/.project-docs/30-worklog/tasks/20260928-teacher-reply-cleanup-c8e4a219.md +++ b/.project-docs/30-worklog/tasks/20260928-teacher-reply-cleanup-c8e4a219.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore-teacher-reply-cleanup - Base commit: ae0f72d8fa0089f87b41d272da220e1c1397aec8 - Owner: codex -- Status: Ready for Integration +- Status: In progress ## Scope @@ -101,3 +101,12 @@ - Verified actual DOM has the new transparent 13px hint, no old fixed footer element, mounted app and current script hash. Read-only composer screenshot inspected at the local trial `hint-preview.png`; it matches the intended light text interaction. 12-second foreground monitor recorded trialFocused=false with unchanged foreground PID 19791. Main focus calls were guarded and none occurred. - Temporary inspector and Main-memory backup removed after verification. Runtime metadata/verification/screenshots remain local and ignored/outside Git. The managed checkout must remain available while this temporary app uses its compiled files. - Project-doc structure and whitespace checks pass. Task-aware drift still reports only the unchanged imported manual-refresh task record from the authorized earlier merge; reconfirmed no difference against 4f6bda4. No canonical or peer task document was edited by this UI follow-up. + +## Playful Retry Follow-up + +- User requests the retry button label “继续看看👀” and a more playful appearance; continue the same temporary-app preview without taking desktop focus. +- Project Context Loaded: same feature task/branch/worktree/base identity above, registry ownership resumed and verified. Reread entry, active task and all peer Scope/Intent/Promotion sections; reuse already-read unchanged positioning, integrated snapshot, teacher decisions, architecture/domain, evidence/reflection and commitment context. Makelore remains a single light creation workspace; scope is only the existing failed/cancelled/interrupted suggestion retry affordance. Separate protocol-policy work stays isolated; old component documents are historical and superseded by this task’s accepted removal. No new semantic conflict. Gate Passed. +- Plan: change label and scoped button CSS to a warm rounded sticker with a small pressed shadow, retaining keyboard/disabled/reduced-motion support; update existing retry tests, inspect a headless screenshot, build and refresh only the trial Renderer with the existing state-preservation method. User-requested emoji overrides the design skill’s generic preference against emoji icons. + +- Implemented exact label “继续看看👀” with a cream-yellow 44px minimum-height sticker button, asymmetrical rounded corners, warm outline and shallow bottom shadow. Hover warms the fill; press lowers the shadow. Disabled and keyboard focus behavior remain, and reduced-motion disables transform/transition. No request or retry behavior changed. +- Passed: 59 existing UI tests updated for the new label, 15 headless layout checks including failed/cancelled retry at 319px with draft preservation, typecheck, full lint, production build and whitespace checks. Inspected the failed-state screenshot; the button fits the narrow panel without clipping. Tests use fixtures only. diff --git a/README.md b/README.md index 60ac8676..4dd1dab7 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 - 学生在当前项目创作时,前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 - 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 -- 智能体支持自由提问;输入框为空时,底部显示轻量文字入口“给我一点提示”,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,结合当前操作对话与咨询历史推荐一个具体切入点,并生成围绕它的快捷回复。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 +- 智能体支持自由提问;输入框为空时,底部显示轻量文字入口“给我一点提示”,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,结合当前操作对话与咨询历史推荐一个具体切入点,并生成围绕它的快捷回复。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接;失败或中断后,可点暖黄色小按钮“继续看看👀”重试。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 - 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。 - “给我一点提示”由老师结合项目推荐一个具体切入点,快捷回复围绕这个切入点帮助学生接话;客户端不要求多个独立话题,也不固定正文长度、选项数量、前缀或表达风格。老师继续依据已配置职责帮助孩子整理想法、理解关系和承接已确认的共识,区分建议与已确认内容。 - Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后展示正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。资源上限只用于防护,不做旧式字数截断;格式错误保留可读正文及原始回答,原文默认折叠、字面显示,不再次送进模型。 diff --git a/src/pages/Chat/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx index df5465f7..49eea574 100644 --- a/src/pages/Chat/TeacherChatPanel.tsx +++ b/src/pages/Chat/TeacherChatPanel.tsx @@ -404,7 +404,7 @@ export function TeacherChatPanel({ {request.suggestedQuestions?.map((question, index) => )} } - {['failed', 'cancelled', 'interrupted'].includes(request.status) && } + {['failed', 'cancelled', 'interrupted'].includes(request.status) && } : request.response ? :
{['running', 'preparing'].includes(request.status) ? {label}正在想… : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}
} {request.intent !== 'suggestions' && request.status === 'completed' && Boolean(request.suggestedQuestions?.length) &&
{request.suggestedQuestions?.map((question, index) => )} diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index 3d107be2..78f75f62 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -51,7 +51,11 @@ .consultation-pane { background: #fffdf8; color: #30465f; } .consultation-hint { color: #5c7087; background: transparent; border: 0; text-underline-offset: 4px; text-decoration: underline transparent; } .consultation-hint:hover:not(:disabled) { color: #30465f; text-decoration-color: #a9bbce; } -.consultation-retry { color: #536747; } +.consultation-retry { display: inline-flex; align-items: center; justify-content: center; align-self: flex-start; min-height: 44px; max-width: 100%; padding: 8px 16px; border: 1.5px solid #e3ce8f; border-radius: 16px 16px 16px 6px; background: #fff2c9; color: #6f5127; box-shadow: 0 3px 0 #e7d7a6, inset 0 1px 0 #fff9e8; font-size: 14px; font-weight: 600; line-height: 24px; cursor: pointer; transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; } +.consultation-retry:hover:not(:disabled) { background: #ffe9a9; border-color: #d6b76f; } +.consultation-retry:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 #e7d7a6, inset 0 1px 0 #fff9e8; } +.consultation-retry:disabled { cursor: not-allowed; opacity: 0.5; } +@media (prefers-reduced-motion: reduce) { .consultation-retry { transition: none; } .consultation-retry:active:not(:disabled) { transform: none; } } .consultation-pane button:focus-visible { outline: 2px solid #71845f; outline-offset: 3px; } .consultation-suggestion { border-color: #e0e6d6; color: #718163; } .consultation-suggestion:hover { border-color: #bac9aa; background: #f4f7ee; } diff --git a/tests/e2e/fixtures/teacher-reply-api.ts b/tests/e2e/fixtures/teacher-reply-api.ts index fc485b11..d6dc27b7 100644 --- a/tests/e2e/fixtures/teacher-reply-api.ts +++ b/tests/e2e/fixtures/teacher-reply-api.ts @@ -10,6 +10,13 @@ const samples: Record = { const scenario = new URLSearchParams(location.search).get('kind') ?? 'ideas'; let current: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'discussion-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 0, seq: i }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: i === 7 ? '可以,我们先把已经想到的放在一起。哪里还不确定,就留着继续聊。' : '先从你最在意的一个小地方开始想。', ...(i === 7 ? { suggestedQuestions: ['有什么好玩的想法?'] } : {}) })), ...(samples[scenario] ? { discussion: { id: 'tool', revision: 1, status: 'active', content: structuredClone(samples[scenario]) } } : {}) }; const stream = Object.assign(new EventTarget(), { close() {}, onerror: null, onopen: null }); +if (scenario === 'retry') { + const status = new URLSearchParams(location.search).get('status') === 'cancelled' ? 'cancelled' : 'failed'; + current.requests = [{ + ...current.requests[0], text: '给我一点提示', intent: 'suggestions', status, response: '', + ...(status === 'failed' ? { error: '这次没连上,我们可以再试一次。' } : {}), + }]; +} if (scenario === 'rich-text') { current.requests = [{ ...current.requests[0], text: '帮我看看项目和最近的讨论', diff --git a/tests/e2e/teacher-reply-layout.spec.ts b/tests/e2e/teacher-reply-layout.spec.ts index c0341d24..84e64d4b 100644 --- a/tests/e2e/teacher-reply-layout.spec.ts +++ b/tests/e2e/teacher-reply-layout.spec.ts @@ -21,6 +21,31 @@ test.beforeAll(async () => { await server.listen(); url = server.resolvedUrls!.local[0]; }); test.afterAll(async () => { await server?.close(); }); +test('retry stickers stay readable and preserve drafts in a 319px consultation', async ({ page }) => { + await page.setViewportSize({ width: 1180, height: 800 }); + await page.addInitScript(() => localStorage.clear()); + for (const status of ['failed', 'cancelled']) { + await page.goto(`${url}?kind=retry&status=${status}&width=319`); + const panel = page.getByTestId('teacher-chat-panel'); + const retry = page.getByRole('button', { name: '继续看看👀', exact: true }); + await expect(retry).toBeEnabled(); + await expect(retry).toHaveText('继续看看👀'); + expect(await retry.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); + const [panelBounds, retryBounds] = await Promise.all([panel.boundingBox(), retry.boundingBox()]); + expect(retryBounds!.x).toBeGreaterThanOrEqual(panelBounds!.x); + expect(retryBounds!.x + retryBounds!.width).toBeLessThanOrEqual(panelBounds!.x + panelBounds!.width); + await panel.screenshot({ path: test.info().outputPath(`retry-${status}-319.png`) }); + const input = page.getByRole('textbox', { name: '向智能体提问' }); + await input.fill('我还有一点自己的想法'); + await retry.click(); + await expect(page.getByText('我们可以先从一个小地方开始。')).toBeVisible(); + await expect(input).toHaveValue('我还有一点自己的想法'); + expect(await page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs)).toEqual([ + { requestId: expect.any(String), text: '给我一点提示', intent: 'suggestions', references: [], sourceConversationId: 'source' }, + ]); + } +}); + for (const width of [319, 508]) { test(`empty-composer hint stays lightweight and keeps the send position stable at ${width}px`, async ({ page }) => { await page.setViewportSize({ width: 1180, height: 800 }); diff --git a/tests/unit/coding-teacher-ui.test.tsx b/tests/unit/coding-teacher-ui.test.tsx index ba79a8fb..8b9ca425 100644 --- a/tests/unit/coding-teacher-ui.test.tsx +++ b/tests/unit/coding-teacher-ui.test.tsx @@ -501,7 +501,7 @@ describe('teacher side chat', () => { await screen.findByText('这次没有看清楚,请再试一次。'); expect(screen.queryByText('{"intro":"未完成')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: suggestedQuestions[0] })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '再请智能体看看', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '继续看看👀', exact: true })); await screen.findByText('我们可以从最近遇到的这两个地方聊起。'); expect(api.send.mock.calls[0][2]).toMatchObject({ intent: 'suggestions' }); expect(api.send.mock.calls[0][2].requestId).not.toBe(failed.id); @@ -529,10 +529,10 @@ describe('teacher side chat', () => { act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(recovered) }))); } await screen.findByText('智能体已接到,但这次回复失败了'); - await waitFor(() => expect(screen.getByRole('button', { name: '再请智能体看看', exact: true })).toBeEnabled()); + await waitFor(() => expect(screen.getByRole('button', { name: '继续看看👀', exact: true })).toBeEnabled()); expect(screen.getByLabelText('向智能体提问')).toHaveValue('我的自由提问继续留着'); expect(api.send).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByRole('button', { name: '再请智能体看看', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '继续看看👀', exact: true })); await screen.findByText('我们可以从最近遇到的这两个地方聊起。'); expect(api.send).toHaveBeenCalledTimes(2); expect(api.send.mock.calls[1][2].requestId).not.toBe(originalRequest.requestId);