Place teacher prompt buttons above the composer
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
# Task: Move configured teacher prompts above composer
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260929-teacher-prompt-buttons-a682d30f
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260929-teacher-prompt-buttons-a682d30f-teacher-prompt-buttons
|
||||
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-teacher-prompt-buttons-a682d30f
|
||||
- Base commit: 78cfde67340aaccea8e8edc2505136cfcf865eda
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Move Yuxi-configured teacher shortcuts above the composer as small, flat pastel buttons; preserve exact configured label/prompt and existing send/voice/model behavior.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- User authorized code and background trial update. No subagents, cloud deployment, paid prompts, main merge/push, focus changes or history deletion.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Configured teacher shortcuts now sit outside and directly above the composer. Buttons use 13px normal text (same as reply body), pale cream fill, light border and no shadow; wrapping/overflow/keyboard focus supported.
|
||||
- Adapted product/tests dependency from main 53d5b6b (bb96941); original source task record remains in its source commit, excluded from this feature boundary. Explicit empty arrays hide prompts; absent historical field retains approved fallback. Published label and prompt are unchanged.
|
||||
- Trial Renderer updated in place in PID 32583; Main and empty-work preview overlay remain intact. Live Alice cloud v53 supplies “继续看看咯”.
|
||||
|
||||
## Verification
|
||||
|
||||
- Typecheck, lint (0 errors / 8 existing warnings), production build passed. 75 focused unit tests and 23 headless browser scenarios passed, including 319/508px overflow, keyboard focus, exact configured prompt sends, preserved drafts, cloud refresh/empty list and voice/model controls.
|
||||
- First test pass exposed two stale assertions in the imported tests (send argument position and old toolbar ownership); adapted them. One rerun hit the existing old-topic stream timing test; serial rerun passed all 75 with no product changes.
|
||||
- Trial screenshot confirms 13px, weight 400, no shadow, buttons above composer, no horizontal overflow. Same account/storage/drafts/teacher/tab; focus monitor reports no trial activation.
|
||||
- Operation hydration initially collided with the existing E2E trial 250ms background-sleep timer while the desktop was at loginwindow. Read-only inspector diagnosis traced stop to BackgroundLifecycleController.sleepIfIdle (same failures present at 11:28Z before this update). A temporary Main-owned snapshot-verification lease allowed normal selectConversation hydration; then released it. No runtime code or user files changed. Final comparison: all 14 operation messages, teacher turns, drafts, selected teacher, route/tab unchanged; 15 timestamps visible. No live prompt or microphone call. All inspector breakpoints were removed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Feature branch awaits separate integration; no main merge/push or cloud deployment in this task. Prior model-selection cloud rollout remains pending.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: integrated classroom UI documentation. Proposal: configured prompts live above the composer as flat body-size buttons; exact cloud labels/prompts, absent/empty compatibility and send guards retained. Evidence: user approval, 75 unit / 23 browser tests and live trial comparison. No product-policy conflict; canonical promotion belongs to integration.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
- Task ID/mode/branch/worktree/base: matching official reservation and owner verified; feature task at 78cfde67340aaccea8e8edc2505136cfcf865eda in the isolated path above.
|
||||
- Read: AGENTS; entry, memory index, active task, positioning, current state, decisions/teacher ADR, architecture, domain rules, success criteria, evidence/commitment indexes and all registered peer Scope/Intent/Promotion sections.
|
||||
- Goal: child-friendly creation with cloud-defined teachers and Main-owned auth/model access. Current code and README supersede stale canonical mentions of retired discussion components.
|
||||
- Active peers: window-close (native lifecycle, explicitly excludes trial), help-label/replies-release, child-language (undefined scope), and completed teacher/composer/community/empty-preview tasks. No concrete semantic conflict; preserve their committed behavior and do not modify peer checkouts.
|
||||
- Relevant dependencies: main 53d5b6b adds configured shortcuts; current trial has 78cfde6 microphone/models and empty-preview 0167d36 overlay. Only shortcut support and presentation are brought into this task.
|
||||
- Files: TeacherChatPanel, classroom-workspace.css, shared definition and focused UI/layout fixtures, README. Existing single light palette and body font remain; design skill recommendations subordinate to approved layout.
|
||||
- Gate: Passed.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Reconcile committed Yuxi shortcuts support with trial composer, preserving empty-array versus absent compatibility.
|
||||
2. Move controls above composer and give them body-size, normal-weight, pale rounded styling with keyboard focus and wrapping.
|
||||
3. Verify UI behavior/layout, typecheck/lint/build; update current trial in background preserving source overlays and user state; document evidence.
|
||||
@@ -183,7 +183,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
|
||||
- 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。
|
||||
- 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。
|
||||
- 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。
|
||||
- 输入框为空时显示当前老师已发布的快捷提示,按 Yuxi 中配置的顺序展示文案,点击直接发送该项实际提示词。Yuxi“编辑 → 快捷提示”支持最多 8 项,每项文案最多 40 字、提示词最多 6000 字;保存并发布后,目录更新即可生效,历史和未发送草稿保留。明确发布空列表隐藏全部按钮;缺少字段的历史版本保留“继续看看👀”。多个长文案在限定高度内滚动,运行中禁止重复发送。上线需先更新 WS 登记接口,再更新 Yuxi 和客户端。
|
||||
- 输入框为空时,在输入框外上方显示当前老师已发布的快捷提示;采用与回复正文一致的 13px 常规字重、浅色圆角按钮,横向排列并自动换行。按 Yuxi 中配置的顺序展示文案,点击直接发送该项实际提示词。Yuxi“编辑 → 快捷提示”支持最多 8 项,每项文案最多 40 字、提示词最多 6000 字;保存并发布后,目录更新即可生效,历史和未发送草稿保留。明确发布空列表隐藏全部按钮;缺少字段的历史版本保留“帮我整体看🧠”。多个长文案在限定高度内滚动,运行中禁止重复发送。上线需先更新 WS 登记接口,再更新 Yuxi 和客户端。
|
||||
- `reply.ts` 只声明正文与快捷回复的输出格式,语言、长度、前缀与表达风格由所选智能体的云端配置决定。客户端不再发送选择、生成、转换或维护展示组件的指令。旧话题内的组件状态、快照和解析诊断作为不透明历史数据原样保留,不校验删除、不恢复交互;旧组件修改路由已移除。
|
||||
- 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用、咨询正文和快捷回复,并支持上述只读原文恢复;旧组件状态、快照和未解析原文不加入模型上下文或咨询历史读取结果。没有跨全部操作会话自动读取,也未向操作 Agent 注入咨询共识。提示词不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地智能体由 Main 调用计费网关;均不创建本地 Pi 编程 worker。
|
||||
- 智能体输入裁剪区分两种计量:本地模型以约每 2 个 UTF-8 字节估算 1 Token,并计入工具与读取预留;这不是精确分词或扣费依据,模型上限与实际 usage 仍由服务端拥有。云端沿用 query 字节上限,以完整 JSON 转义后的大小裁剪来源节选。智能体指令、当前问题和明确引用不会被静默裁掉;固定配置或回复协议超限时明确提示调整配置,避免让学生反复缩短短问题或新建话题。完整来源仍可由只读工具按需读取。
|
||||
|
||||
@@ -606,20 +606,21 @@ export function TeacherChatPanel({
|
||||
</div>
|
||||
</details>}
|
||||
{references.length > 0 && <div className="mb-2 rounded-lg bg-white p-3 text-xs"><p className="line-clamp-3 whitespace-pre-wrap">{references.map((ref) => ref.text).join('\n')}</p><button type="button" className="mt-1 underline" onClick={removeReferences}>移除引用</button></div>}
|
||||
{!draftRevision && !text.trim() && (definition?.shortcuts == null || definition.shortcuts.length > 0) &&
|
||||
<div className="consultation-shortcuts" role="group" aria-label="快捷提问" data-testid="consultation-shortcuts">
|
||||
{definition?.shortcuts == null
|
||||
? <button type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'} onClick={askForSuggestions} className="consultation-hint">帮我整体看🧠</button>
|
||||
: definition.shortcuts.map((shortcut, index) => <button
|
||||
key={index} type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'}
|
||||
onClick={() => void send({ text: shortcut.prompt })}
|
||||
className="consultation-hint"
|
||||
>{shortcut.label}</button>)}
|
||||
</div>}
|
||||
<div className="consultation-composer rounded-2xl border bg-white p-3 focus-within:ring-2 focus-within:ring-black/5">
|
||||
<textarea ref={inputRef} aria-label={`向${label}提问`} value={text} disabled={busy} maxLength={6000} onChange={(e) => changeText(e.target.value)} placeholder="和老师聊聊" rows={2}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void send(); } }}
|
||||
className="min-h-[60px] w-full resize-none border-0 bg-transparent text-[13px] leading-6 outline-none placeholder:text-muted-foreground/65" />
|
||||
<div className="mt-2 flex min-h-[68px] flex-wrap items-center justify-between gap-x-2 gap-y-2" data-testid="consultation-composer-actions">
|
||||
<div className="flex max-h-48 min-w-0 grow flex-wrap gap-2 overflow-y-auto pb-1">
|
||||
{!draftRevision && !text.trim() && (definition?.shortcuts == null
|
||||
? <button type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'} onClick={askForSuggestions} className="consultation-hint">帮我整体看🧠</button>
|
||||
: definition.shortcuts.map((shortcut, index) => <button
|
||||
key={index} type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'}
|
||||
onClick={() => void send({ text: shortcut.prompt })}
|
||||
className="consultation-hint max-w-full whitespace-normal break-words text-left"
|
||||
>{shortcut.label}</button>))}
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-0 max-w-full items-center gap-1" data-testid="teacher-composer-runtime-controls">
|
||||
<TeacherModelSelect key={modelScope + (definition?.runtime ?? 'local')} runtime={definition?.runtime ?? 'local'} scope={modelScope} defaultModel={definition?.model.model_id}
|
||||
modelId={modelChoice.modelId} onChange={modelChoice.select} disabled={busy || !topicEnabled || Boolean(running)} />
|
||||
|
||||
@@ -49,9 +49,14 @@
|
||||
.classroom-consultation-resizer { position: absolute; inset: 0 auto 0 -4px; width: 8px; z-index: 2; cursor: col-resize; }
|
||||
.classroom-consultation-resizer:hover, .classroom-consultation-resizer:focus-visible { background: #7994b533; }
|
||||
.consultation-pane { background: #fffdf8; color: #30465f; }
|
||||
.consultation-hint, .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-hint:hover:not(:disabled), .consultation-retry:hover:not(:disabled) { background: #ffe9a9; border-color: #d6b76f; }
|
||||
.consultation-hint:active:not(:disabled), .consultation-retry:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 #e7d7a6, inset 0 1px 0 #fff9e8; }
|
||||
.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-shortcuts { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; max-height: min(192px, 24vh); overflow-y: auto; margin-bottom: 10px; padding: 3px; }
|
||||
.consultation-hint { display: inline-flex; align-items: center; max-width: 100%; min-height: 36px; padding: 6px 12px; border: 1px solid #e5ddc7; border-radius: 12px; background: #faf5e8; color: #5b503b; font-size: 13px; font-weight: 400; line-height: 22px; text-align: left; white-space: normal; overflow-wrap: anywhere; cursor: pointer; transition: background-color 160ms ease, border-color 160ms ease; }
|
||||
.consultation-hint:hover:not(:disabled) { background: #f4ecd6; border-color: #d7c9a5; }
|
||||
.consultation-hint:active:not(:disabled) { background: #eee3c7; }
|
||||
.consultation-hint:focus-visible { outline: 2px solid #7891af; outline-offset: 2px; }
|
||||
.consultation-hint:disabled, .consultation-retry:disabled { cursor: not-allowed; opacity: 0.5; }
|
||||
@media (prefers-reduced-motion: reduce) { .consultation-hint, .consultation-retry { transition: none; } .consultation-hint:active:not(:disabled), .consultation-retry:active:not(:disabled) { transform: none; } }
|
||||
.consultation-pane button:focus-visible { outline: 2px solid #71845f; outline-offset: 3px; }
|
||||
|
||||
@@ -56,6 +56,15 @@ for (const width of [319, 508]) {
|
||||
await expect(shortcuts.first()).toHaveText('看看进展');
|
||||
await expect(shortcuts.nth(1)).toHaveText('解释代码');
|
||||
await expect(shortcuts.nth(1)).toBeEnabled();
|
||||
const row = panel.getByRole('group', { name: '快捷提问' });
|
||||
const composer = panel.locator('.consultation-composer');
|
||||
await expect(composer.locator('.consultation-hint')).toHaveCount(0);
|
||||
await expect(shortcuts.first()).toHaveCSS('font-size', await panel.getByTestId('teacher-reply').first().evaluate(el => getComputedStyle(el).fontSize));
|
||||
await expect(shortcuts.first()).toHaveCSS('font-weight', '400');
|
||||
await expect(shortcuts.first()).toHaveCSS('box-shadow', 'none');
|
||||
expect((await row.boundingBox())!.y + (await row.boundingBox())!.height).toBeLessThan((await composer.boundingBox())!.y);
|
||||
await shortcuts.first().focus();
|
||||
await expect(shortcuts.first()).toHaveCSS('outline-style', 'solid');
|
||||
const form = (await panel.locator('form').boundingBox())!;
|
||||
expect(form.y + form.height).toBeLessThanOrEqual(800);
|
||||
expect(await panel.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
@@ -120,12 +129,12 @@ for (const width of [319, 508]) {
|
||||
await expect(hint).toBeEnabled();
|
||||
await expect(hint).toHaveText('帮我整体看🧠');
|
||||
expect(await hint.evaluate(el => el.scrollWidth <= el.clientWidth && el.scrollHeight <= el.clientHeight)).toBe(true);
|
||||
await expect(hint).toHaveCSS('background-color', 'rgb(255, 242, 201)');
|
||||
await expect(hint).toHaveCSS('background-color', 'rgb(250, 245, 232)');
|
||||
const initialSend = (await send.boundingBox())!;
|
||||
const hintBounds = (await hint.boundingBox())!;
|
||||
expect(hintBounds.x).toBeLessThan(initialSend.x);
|
||||
expect(hintBounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(hintBounds.x + hintBounds.width).toBeLessThanOrEqual(initialSend.x);
|
||||
expect(hintBounds.height).toBeGreaterThanOrEqual(36);
|
||||
expect(hintBounds.y + hintBounds.height).toBeLessThan((await page.locator('.consultation-composer').boundingBox())!.y);
|
||||
await page.getByTestId('teacher-chat-panel').screenshot({ path: test.info().outputPath(`composer-hint-${width}.png`) });
|
||||
expect(await sent()).toEqual([]);
|
||||
await input.fill('我想先自己说说');
|
||||
|
||||
@@ -108,22 +108,22 @@ describe('teacher side chat', () => {
|
||||
api.sendConversation.mockRejectedValue(new Error('结果暂未确认'));
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={agent} />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '看看进展' })).toBeEnabled());
|
||||
expect([...screen.getByTestId('consultation-composer-actions').querySelectorAll('.consultation-hint')].map(el => el.textContent)).toEqual(shortcuts.map(item => item.label));
|
||||
expect(screen.queryByRole('button', { name: '继续看看👀' })).not.toBeInTheDocument();
|
||||
expect([...screen.getByTestId('consultation-shortcuts').querySelectorAll('.consultation-hint')].map(el => el.textContent)).toEqual(shortcuts.map(item => item.label));
|
||||
expect(screen.queryByRole('button', { name: '帮我整体看🧠' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(shortcuts[0].prompt)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '看看进展' }));
|
||||
await screen.findByText('结果暂未确认');
|
||||
const original = api.sendConversation.mock.calls[0][2];
|
||||
const original = api.sendConversation.mock.calls[0][1];
|
||||
expect(original).toMatchObject({ text: shortcuts[0].prompt, projectId: 'p', sourceConversationId: 'c', references: [] });
|
||||
expect(original.intent).toBeUndefined();
|
||||
fireEvent.click(screen.getByRole('button', { name: '看看进展' }));
|
||||
await waitFor(() => expect(api.sendConversation).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '解释代码' })).toBeEnabled());
|
||||
expect(api.sendConversation.mock.calls[1][2]).toEqual(original);
|
||||
expect(api.sendConversation.mock.calls[1][1]).toEqual(original);
|
||||
fireEvent.click(screen.getByRole('button', { name: '解释代码' }));
|
||||
await waitFor(() => expect(api.sendConversation).toHaveBeenCalledTimes(3));
|
||||
expect(api.sendConversation.mock.calls[2][2].text).toBe(shortcuts[1].prompt);
|
||||
expect(api.sendConversation.mock.calls[2][2].requestId).not.toBe(original.requestId);
|
||||
expect(api.sendConversation.mock.calls[2][1].text).toBe(shortcuts[1].prompt);
|
||||
expect(api.sendConversation.mock.calls[2][1].requestId).not.toBe(original.requestId);
|
||||
});
|
||||
|
||||
it('updates published shortcuts without replacing chat history or a composed draft, and supports clearing the list', async () => {
|
||||
@@ -146,7 +146,7 @@ describe('teacher side chat', () => {
|
||||
expect(screen.queryByRole('button', { name: '旧入口' })).not.toBeInTheDocument();
|
||||
const cleared = { ...updated, version: 3, definition: { ...updated.definition, shortcuts: [] } };
|
||||
view.rerender(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={cleared} catalogState={state(cleared)} />);
|
||||
expect(screen.getByTestId('consultation-composer-actions').querySelectorAll('.consultation-hint')).toHaveLength(0);
|
||||
expect(screen.queryByTestId('consultation-shortcuts')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('之前的回答')).toBeVisible();
|
||||
expect(api.sendConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -550,7 +550,8 @@ describe('teacher side chat', () => {
|
||||
const input = screen.getByLabelText('向智能体提问');
|
||||
const help = () => screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' });
|
||||
await waitFor(() => expect(help()).toBeEnabled());
|
||||
expect(screen.getByTestId('consultation-composer-actions')).toContainElement(help());
|
||||
expect(screen.getByTestId('consultation-shortcuts')).toContainElement(help());
|
||||
expect(screen.getByTestId('consultation-composer-actions')).not.toContainElement(help());
|
||||
expect(help()).toHaveAccessibleName('帮我整体看🧠');
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
fireEvent.change(input, { target: { value: '我先自己想想' } });
|
||||
|
||||
Reference in New Issue
Block a user