diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx index a85c6a51a..3dc7c91b2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx @@ -136,6 +136,25 @@ describe('更早历史的入口', () => { fireEvent.click(view.getByText('重试')); expect(retry).toHaveBeenCalledTimes(1); }); + + it('断言式 live region 里不放可交互控件:重试按钮是 alert 之外的兄弟', () => { + const retry = vi.fn(); + const view = renderConversation({ + historyHasMore: true, + historyError: '读取更早的对话历史失败', + onRetryEarlierHistory: retry, + }); + + // `role="alert"` 隐含 aria-live="assertive" + aria-atomic="true":整段会被当成一条断言性 + // 播报,交互控件嵌在里面既可能不被读屏当成可聚焦按钮,点击也落在 live region 内部。 + const alert = view.getByRole('alert'); + expect(alert.textContent).toContain('加载更早对话失败'); + expect(alert.contains(view.getByText('重试'))).toBe(false); + + // 拆开之后仍在同一行里,重试仍是唯一的手动出路。 + fireEvent.click(view.getByText('重试')); + expect(retry).toHaveBeenCalledTimes(1); + }); }); describe('前插锚点的标记', () => { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectHistoryRow.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectHistoryRow.tsx index 66672ce56..8e00c099c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectHistoryRow.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectHistoryRow.tsx @@ -37,11 +37,10 @@ export function DirectProjectHistoryErrorRow({ onRetry: () => void; }) { return ( -

- {HISTORY_ERROR_TEXT} +

+ {/* `role="alert"` 隐含 assertive + atomic:只有文案本身进 live region,重试按钮留在外面, + 否则整段会被当成一条断言性播报,可聚焦控件也被卷进 live region 内部。 */} + {HISTORY_ERROR_TEXT} -

+
); } diff --git a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md index e450c4f8b..3065b8b6b 100644 --- a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md +++ b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md @@ -21,7 +21,7 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme - 触发二(填充视口):首帧之后内容填不满视口(`scrollHeight <= clientHeight`)时继续加载,直到填满或 `hasMore=false`;不允许出现「历史比视口短、又没有按钮」的死局。 - 两个触发都不越过既有的首屏订阅锚点 `lastCompletedItemId`;一次加载仍最多连拉 5 页(口径见 [`【ADR】DirectProject对话历史单一事实源-2026-09-16`](./【ADR】DirectProject对话历史单一事实源-2026-09-16.md))。 - 加载中在列表最上方(比最旧一条回合更靠上)挂载一行 `role="status"`、`aria-live="polite"` 的「正在加载更早的对话」,带旋转圈;延迟 150ms 才显示,加载结束即卸载。它按需挂载,靠位置补偿(见第 2 条)保证下面的消息不跳。 -- 失败:挂起自动加载,列表顶部保留一行 `role="alert"` 的「加载更早对话失败 · 重试」,重试是可聚焦按钮;**不自动重试**,只有点重试(或切换项目)才重新开始;重试成功后错误行消失。 +- 失败:挂起自动加载,列表顶部保留一行内联错误行——`role="alert"` 只包住「加载更早对话失败」文案本身,重试是可聚焦按钮、留在 live region 之外(assertive + atomic 的 live region 里不放交互控件);**不自动重试**,只有点重试(或切换项目)才重新开始;重试成功后错误行消失。 - 一次加载与它所属的**世代**绑定:切换项目或新起一次读取都推进世代号(`historyLoadTokenRef`),旧世代落地时整段失效——不合并条目、不写游标、不关加载态。只比项目路径不够:A→B→A 之后在飞的旧读取又落回同一个路径,原守卫放行,会把新一代的加载行与 `historyLoadingRef` 这道并发闸门一起改掉。 ### 2. 前插锚定:回合 key + 块身份 + 偏移,关掉浏览器原生锚定