From 89d0f507e641768d221cd1cd0752506da9fb3ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 10:49:41 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E7=BF=BB=E9=A1=B5=E6=97=B6=E5=8E=86=E5=8F=B2=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E8=A1=8C=E5=BF=BD=E9=9A=90=E5=BF=BD=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 加载行卸载加入 300ms 隐藏滞回:填充视口的自动加载连续翻页,两次之间只有一次 effect 回流,立即卸载会把加载行一帧内拆了又挂 - conversationScrollPolicy.ts 新增 HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS,与显示延迟 150ms 配对 - useDelayedFlag 增加 hideDelayMs 参数,值转假后延迟再隐藏,转真仍立即显示 - 补回归测试:连续翻页之间小于隐藏延迟的空隙不卸载加载行,真正结束后照常卸载 - ADR 第 1 节记录隐藏滞回的动机与阈值 --- .../conversationScrollPolicy.ts | 9 +++++ .../useConversationScroll.test.tsx | 37 +++++++++++++++++++ .../useDelayedFlag.ts | 22 +++++++---- ...tProject对话滚动与历史自动加载-2026-10-02.md | 5 ++- 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts index af8d578c6..b0dea574c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts @@ -20,6 +20,15 @@ export const DIRECT_SCROLL_BOTTOM_THRESHOLD = 48; /** 加载行延迟多少毫秒才显示:本地读取常常瞬间返回,立即显示会闪一下。 */ export const HISTORY_LOADING_INDICATOR_DELAY_MS = 150; +/** + * 加载行显示后,加载态转假再等多少毫秒才卸载。 + * + * 填充视口的自动加载是**连续翻页**的:一次加载落地后下一帧就又起一次,两次之间只有一个 effect + * 回流。立即卸载会把加载行一帧内拆了又挂(表现为「正在加载更早的对话」忽隐忽现),这段隐藏延迟 + * 让连续加载之间的小空隙不触发卸载;真正的结束(超时后仍为假)照常卸载。 + */ +export const HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS = 300; + export const HISTORY_LOADING_TEXT = '正在加载更早的对话'; export const HISTORY_ERROR_TEXT = '加载更早对话失败'; export const HISTORY_RETRY_TEXT = '重试'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx index 620ec09f2..51e8a502b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx @@ -387,6 +387,43 @@ describe('加载行', () => { vi.useRealTimers(); } }); + + it('连续翻页之间的小空隙不卸载加载行:加载态不再忽隐忽现', () => { + vi.useFakeTimers(); + try { + stubListMetrics({ scrollTop: 0, scrollHeight: 1200, clientHeight: 400 }); + const props = { + turns: [] as DirectChatTurn[], + historyHasMore: true, + historyError: null, + onLoadEarlierHistory: () => undefined, + }; + const view = render(); + act(() => { + vi.advanceTimersByTime(150); + }); + expect(view.queryByTestId('loading')).not.toBeNull(); + + // 第一页落地:加载态转假,但填充视口的判据下一帧又会起一次,两次之间只隔一次 effect 回流。 + view.rerender(); + act(() => { + vi.advanceTimersByTime(50); + }); + view.rerender(); + + // 小于隐藏延迟的空隙不卸载:加载行不会在两次翻页之间消失重现。 + expect(view.queryByTestId('loading')).not.toBeNull(); + + // 真正结束(转假并超过隐藏延迟)才卸载。 + view.rerender(); + act(() => { + vi.advanceTimersByTime(301); + }); + expect(view.queryByTestId('loading')).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); }); describe('点「回到底部」后的程序化滚动', () => { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts index 41a5c6852..f504cf60a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts @@ -1,22 +1,28 @@ import { useEffect, useState } from 'react'; /** - * 延迟显示一个开关位:为真后等 `delayMs` 才转成可见,转假立即隐藏。 + * 延迟显示一个开关位:为真后等 `delayMs` 才转成可见,转假后再等 `hideDelayMs` 才隐藏。 * - * 用来避免「本地读取瞬间返回」时闪一下加载行:反馈要等得起,但不能一闪而过。 + * `delayMs` 用来避免「本地读取瞬间返回」时闪一下加载行:反馈要等得起,但不能一闪而过。 + * `hideDelayMs` 是反向的滞回:连续加载之间只隔一次 effect 回流,立即隐藏会让加载行一帧内反复 + * 挂卸。默认 0 表示转假即隐藏,保持「只延迟显示」的原语义。 */ -export function useDelayedFlag(value: boolean, delayMs: number): boolean { +export function useDelayedFlag( + value: boolean, + delayMs: number, + hideDelayMs = 0, +): boolean { const [visible, setVisible] = useState(false); useEffect(() => { - if (!value) { - setVisible(false); - return undefined; + if (value) { + const timer = setTimeout(() => setVisible(true), delayMs); + return () => clearTimeout(timer); } - const timer = setTimeout(() => setVisible(true), delayMs); + const timer = setTimeout(() => setVisible(false), hideDelayMs); return () => clearTimeout(timer); - }, [value, delayMs]); + }, [value, delayMs, hideDelayMs]); return visible; } diff --git a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md index 79c98aa6f..22fb1dd55 100644 --- a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md +++ b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md @@ -20,7 +20,8 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme - 触发一(滚动):`scrollTop <= 24` 且 `historyHasMore` 且不在加载中且没有失败记录时自动加载。 - 触发二(填充视口):首帧之后内容填不满视口(`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="status"`、`aria-live="polite"` 的「正在加载更早的对话」,带旋转圈;延迟 150ms 才显示,加载态转假后再留 300ms 才卸载(隐藏滞回)。它按需挂载,靠位置补偿(见第 2 条)保证下面的消息不跳。 + - 隐藏滞回是必须的:填充视口的自动加载是连续翻页的(一次加载落地后下一帧又起一次),两次之间只有一个 effect 回流;立即卸载会把加载行一帧内拆了又挂。全是工具调用时更明显——工具组折在收起的 `
` 里,每页几乎不增加可见高度,视口一直填不满,加载行就在「页与页之间」忽隐忽现。300ms 的隐藏延迟让连续加载之间的小空隙不卸载,真正结束(超时后仍为假)照常卸载。 - 失败:挂起自动加载,列表顶部保留一行内联错误行——`role="alert"` 只包住「加载更早对话失败」文案本身,重试是可聚焦按钮、留在 live region 之外(assertive + atomic 的 live region 里不放交互控件);**不自动重试**,只有点重试(或切换项目)才重新开始;重试成功后错误行消失。 - 一次加载与它所属的**世代**绑定:切换项目或新起一次读取都推进世代号(`historyLoadTokenRef`),旧世代落地时整段失效——不合并条目、不写游标、不关加载态。只比项目路径不够:A→B→A 之后在飞的旧读取又落回同一个路径,原守卫放行,会把新一代的加载行与 `historyLoadingRef` 这道并发闸门一起改掉。换项目的推进放在**渲染期**(与 `projectPathRef` 同一处),不放在复位 effect 里:passive effect 走宏任务、promise 续体走微任务,旧读取可能在「切换提交完成、复位 effect 还没跑」的窗口里落地,那时世代号还是旧的,守卫会放行。 @@ -80,6 +81,6 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme - 历史加载失败不再只写顶部状态行,而是落到列表里的内联错误行;顶部状态行仍保留首屏读取失败等其它用途。 - 滚动是表现层行为,正式状态仍在后端投影与运行态事件;本 ADR 不新增领域概念。 -- 阈值(触顶 24px、贴底 48px、spinner 150ms)是可按手感调整的常量,集中放在 `components/DirectProjectConversation/conversationScrollPolicy.ts`。 +- 阈值(触顶 24px、贴底 48px、spinner 显示 150ms / 隐藏滞回 300ms)是可按手感调整的常量,集中放在 `components/DirectProjectConversation/conversationScrollPolicy.ts`。 - 验收:纯函数与 jsdom 组件测试覆盖阈值、锚点还原(含收口后按块身份仍指向同一块、锚点块被折叠隐藏时放弃还原)、加载/错误行、胶囊文案与显隐;滚动观感(顶部加载圈、胶囊出现与消失、底部展开回贴、历史前插不跳、长回合收口时视口不跳、切项目后首屏贴底且不误报「有新回复」)必须真机手动验收——jsdom 没有布局。 - 明确的后续项(不在本次范围):`PlanningChatView` 与 `App.tsx` 遗留 `message-history-more` 路径的同款改造、未读条数徽标、Playwright 端到端。 From c95ea8af854d6c423ce035a187ca965b318bfb9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 10:49:56 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E7=9F=AD=E5=8E=86=E5=8F=B2=E4=B8=80?= =?UTF-8?q?=E5=B1=8F=E4=BB=A5=E5=86=85=E6=97=B6=E6=94=B6=E8=B5=B7=E6=82=AC?= =?UTF-8?q?=E7=A9=BA=E7=9A=84=E3=80=8C=E5=9B=9E=E5=88=B0=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E3=80=8D=E8=83=B6=E5=9B=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useConversationScroll 新增 syncBottomFromLayout:按真实几何复核 isNearBottom,为真即恢复跟随、收起胶囊并清掉「有新回复」 - 布局补偿、turns 内容变化与 ResizeObserver/MutationObserver 回调都先走一次复核,内容塌回一屏以内不再依赖滚动事件 - 补回归测试:列表内容塌回一屏以内并触发布局变化,没有任何滚动事件也应收起胶囊 - ADR 第 3 节记录贴底判定不能只认 onScroll --- .../useConversationScroll.test.tsx | 31 ++++++++++++++++ .../useConversationScroll.ts | 37 +++++++++++++++++-- ...tProject对话滚动与历史自动加载-2026-10-02.md | 1 + 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx index 51e8a502b..8599be37f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx @@ -356,6 +356,37 @@ describe('回到底部胶囊', () => { ); expect(capsule()).toBe('回到底部'); }); + + it('内容塌回一屏以内:没有滚动事件也收起胶囊', () => { + const metrics: ListMetrics = { + scrollTop: 0, + scrollHeight: 1200, + clientHeight: 400, + }; + stubListMetrics(metrics); + vi.stubGlobal('ResizeObserver', FakeResizeObserver); + const view = render( + undefined} + />, + ); + const capsule = () => view.getByTestId('capsule').textContent; + + metrics.scrollTop = 0; + fireEvent.scroll(view.getByTestId('list')); + expect(capsule()).toBe('回到底部'); + + // 回合收口把过程折进收起的 `
`:内容塌回一屏以内,列表不会再发任何滚动事件。 + // 只认 onScroll 里那次判定的 atBottom 会把胶囊永远挂在一个滚不动的列表上。 + metrics.scrollHeight = 400; + FakeResizeObserver.triggerLayoutChange(); + + expect(capsule()).toBe(''); + }); }); describe('加载行', () => { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts index ca00bc8fc..9f12d8a91 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts @@ -17,6 +17,7 @@ import { } from './conversationScrollAnchor'; import { HISTORY_LOADING_INDICATOR_DELAY_MS, + HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS, isNearBottom, readConversationListMetrics, scrollToBottomLabel, @@ -127,6 +128,7 @@ export function useConversationScroll({ const showHistoryLoading = useDelayedFlag( historyLoading, HISTORY_LOADING_INDICATOR_DELAY_MS, + HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS, ); /** @@ -141,6 +143,26 @@ export function useConversationScroll({ loadEarlierRef.current(); }, []); + /** + * 按真实几何复核「在底部」,返回复核结果。 + * + * `atBottom` 的唯一常规来源是 `onScroll`,但内容**变短**到一屏以内时不会有滚动事件:回合收口把 + * 过程折进收起的 `
`、历史加载行卸载,都会让 `scrollHeight` 掉回 `clientHeight` 以下, + * 之后列表根本滚不动,可 `atBottom` 还停在离开底部时的假值——「回到底部」胶囊就永远挂在一个没有 + * 可滚余量的列表上。布局变化与内容变化都是复核点。 + * + * 复核为真时同步恢复跟随并清掉「有新回复」:都已经滚无可滚了,留着未读提示只会是指向空处的入口。 + */ + const syncBottomFromLayout = useCallback((): boolean => { + const list = listRef.current; + if (!list) return followLatestRef.current; + if (!isNearBottom(readConversationListMetrics(list))) return false; + followLatestRef.current = true; + setAtBottom(true); + setHasNewReply(false); + return true; + }, []); + /** * 布局变化后的补偿:跟随时贴底;否则优先冻结刚展开的折叠头,再退到前插锚点。 * @@ -151,6 +173,9 @@ export function useConversationScroll({ const list = listRef.current; if (!list) return; + // 先按真实几何复核贴底:内容塌回一屏以内时不会再有任何滚动事件来翻转 `atBottom`。 + syncBottomFromLayout(); + // 程序化滚动在飞:滚动位置归这次动画所有,直接写 `scrollTop` 会把动画取消在半路。 // 但内容在这期间变高(流式正文、图片撑开)时,点击瞬间记下的目标已经不是底部了——动画会 // 停在旧目标上,而 `programmaticScrollRef` 只在「贴底」那次滚动事件里交还,于是跟随与补偿 @@ -187,7 +212,7 @@ export function useConversationScroll({ // 锚点还原失败(块已经不在列表里,或收口后被折进收起的 `
`、没有布局盒算不出偏移): // 拿当前位置重新起锚,别用过期基准写 `scrollTop`;只是这次不自动对齐,不会回跳。 preserveAnchorRef.current = readTopVisibleTurnAnchor(list); - }, []); + }, [syncBottomFromLayout]); // 换会话:列表容器不重挂载,滚动所有权必须显式复位,否则上一个会话的「已离开底部」 // 会漏到新会话。必须先于下面那条「内容变化」的 effect:终态指纹要先同步成新会话的内容, @@ -214,8 +239,11 @@ export function useConversationScroll({ scrollListToBottom(listRef.current, 'auto'); return; } + // 回合收口会把过程折进收起的 `
`,内容可能就此塌回一屏以内:先按真实几何复核, + // 已经滚无可滚就不再点亮「有新回复」(那时列表里根本没有可去的底部)。 + if (syncBottomFromLayout()) return; if (gainedTerminalContent) setHasNewReply(true); - }, [turns]); + }, [syncBottomFromLayout, turns]); // 观察列表的直接子元素:展开 / 收起、流式正文增长、图片撑高、历史前插都只有这一个入口。 // 子元素增删(回合追加、加载行挂卸)由 MutationObserver 触发重订阅,同时让锚点的块集合缓存失效。 @@ -232,6 +260,9 @@ export function useConversationScroll({ for (const child of Array.from(list.children)) { observer.observe(child); } + // 子元素增删本身就可能让内容长度跨过视口(历史加载行挂卸、回合收口折叠),而这不会命中任何 + // 被观察子元素的高度变化:这里按真实几何补一次贴底复核,避免胶囊赖在滚不动的列表上。 + syncBottomFromLayout(); }; subscribe(); const mutations = new MutationObserver(subscribe); @@ -241,7 +272,7 @@ export function useConversationScroll({ mutations.disconnect(); observer?.disconnect(); }; - }, [compensateLayout]); + }, [compensateLayout, syncBottomFromLayout]); // 程序化滚动被用户接手(滚轮 / 触摸 / 键盘)时立刻交还控制权。 // 动画被打断后浏览器不会再发贴底滚动事件,标记不交还就会永远挂着,后续跟随与补偿全失效。 diff --git a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md index 22fb1dd55..dc543d888 100644 --- a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md +++ b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md @@ -40,6 +40,7 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme - 列表底部居中的悬浮胶囊(`sticky`),跟着滚动容器走、不随内容滚走。 - 距底部超过 48px 时出现,文案「回到底部」;用户不跟随时来了新的终态内容就改成「有新回复 · 回到底部」。 +- 贴底判定不能只认 `onScroll`:内容**变短**到一屏以内时不会再有任何滚动事件(回合收口把过程折进收起的 `
`、历史加载行卸载),`atBottom` 会停在离开底部时的假值,胶囊就永远挂在一个滚不动的列表上(短历史一屏显示完却还悬着「回到底部」)。因此每次布局变化(`ResizeObserver`)、子元素增删(`MutationObserver`)与内容变化后都按真实几何复核一次 `isNearBottom`,复核为真即收起胶囊并清掉「有新回复」。 - 点击:平滑滚到底部 + 恢复跟随最新 + 清除「有新回复」,随后按钮自行消失。 - 平滑滚动期间滚动位置归这次程序化滚动所有:滚动事件不再翻转「跟随最新」,布局补偿也不写 `scrollTop`(写一次就会取消动画并把画面拉回原处,表现为「点了只下去一屏、到不了底」)。滚到贴底阈值即交还控制权;用户中途用滚轮 / 触摸 / 键盘打断则立刻交还,不会卡住后续跟随。 - 动画期间内容变高(流式正文、图片撑开)时,点击瞬间记下的 `scrollHeight` 已经不是底部:补偿不写 `scrollTop`,而是把动画目标重新对准新的底部。否则动画停在旧目标上、等不到「贴底」那次滚动事件,`programmaticScrollRef` 不会交还——跟随与布局补偿整段挂起,而 `scrollToBottom` 已把胶囊按「已贴底」隐掉,用户停在底部之上却没有任何指示与自动跟随。 From d860b84a02c82fbbf9b144b72d745106a2c29ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 10:50:04 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E3=80=8C=E5=9B=9E=E5=88=B0=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E3=80=8D=E8=83=B6=E5=9B=8A=E6=94=B9=E4=B8=BA=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=E5=88=97=E8=A1=A8=E5=8F=AF=E8=A7=81=E5=BA=95=E8=BE=B9?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - styles.css 把消息列表改成 flex 列容器并显式 gap: 0——原 gap: 14px 对块级容器无效,改成 flex 后会与子项的 margin 叠成双倍间距 - 列表子项 flex: 0 0 auto 防止内容溢出一屏时被纵向压缩,列表仍是唯一滚动区 - 胶囊 margin-top: auto:内容不足一屏时吃掉剩余空间顶到可见底边,内容溢出一屏时归零、交由 sticky bottom-3 上拉贴底 - DirectProjectScrollToBottomCapsule 增加稳定类名 project-chat-scroll-to-bottom、去掉 mt-1,并更新组件说明 - ADR 第 3 节与备选方案 2 同步新的定位方式 --- apps/ai-game-creator-shell/src/styles.css | 40 +++++++++++++++++-- .../DirectProjectScrollToBottomCapsule.tsx | 16 ++++++-- ...tProject对话滚动与历史自动加载-2026-10-02.md | 4 +- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 8c3b2535d..511e2ea4a 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -13401,10 +13401,11 @@ button.design-workspace-tree__entry:hover, grid-row: 1 / span 2; } -/* 消息之间的间距:消息列表是块级滚动容器(不是 flex/grid),基础规则里的 `gap: 14px` - 对它无效,而被改写成 `margin-top: 0` 的 `.message + .message` 又让相邻消息贴在一起。 - 这里给列表的相邻子元素统一加间距(消息、工具调用折叠块、思考过程块都适用), - 间距值与消息内部的行距观感一致。 */ +/* 消息之间的间距:消息列表是块级滚动流,基础规则里的 `gap: 14px` 对它无效,而被改写成 + `margin-top: 0` 的 `.message + .message` 又让相邻消息贴在一起。这里给列表的相邻子元素统一 + 加间距(消息、工具调用折叠块、思考过程块都适用),间距值与消息内部的行距观感一致。 + (列表下面被改成 flex 列容器,只是为了给「回到底部」胶囊一个 `margin-top: auto` 的落点; + flex 里 margin 不折叠,这条间距规则仍照常生效。) */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-message-list @@ -13413,6 +13414,37 @@ button.design-workspace-tree__entry:hover, margin-top: 14px; } +/* 「回到底部」胶囊的位置真源:把消息列表做成 **flex 列容器**,胶囊是最后一个 flex 项。 + - 内容不足一屏:`margin-top: auto` 吃掉剩余空间,把胶囊顶到列表可见底边; + - 内容溢出一屏:auto 归零,胶囊落回内容末尾,再由自身的 `sticky bottom-3` 上拉贴底。 + 只有 sticky 是不够的:sticky 只能把元素**上拉**,不能下推——内容不足一屏时胶囊会停在 + 文档流里(表现为悬空,见 `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`)。 + `gap: 0` 必须显式写:上面 `.… .project-chat-message-list { gap: 14px }` 是给 flex 预备的, + 改成 flex 后它会生效,与下面那些 `> * + *` 的 margin 叠成双倍间距。 + `> * { flex: 0 0 auto }` 保证内容溢出一屏时子项不被纵向压缩,列表仍是唯一滚动区; + 子项间距仍由上面那条 `> * + *` 给(flex 里 margin 不折叠,差值与块级最多 2px)。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-message-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-message-list + > * { + flex: 0 0 auto; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-message-list + > .project-chat-scroll-to-bottom { + margin-top: auto; +} + /* 发送时间与完成后的过程区沿用对话面板的次要信息样式。 */ .message-sent-at { display: block; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx index cbf78edf9..6814a56df 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx @@ -3,9 +3,17 @@ import { ArrowDown } from 'lucide-react'; /** * 底部居中的「回到底部」胶囊。 * - * 用 `sticky bottom-*` 而不是浮层:工作台里 `.project-chat-conversation` 是 `display: block` - * 加 `height: 100%` 的几何,在列表外套一层定位容器会把列表的 `height: 100%` 塌成内容高度; - * 粘在列表内部的胶囊零结构改动,两块宿主(工作台侧栏与独立页面)都能拿到。 + * 它是消息列表的**最后一个 flex 项**,贴底靠两件事配合(见 `styles.css` 里 + * `.project-chat-scroll-to-bottom` 那组规则): + * - `margin-top: auto`:内容不足一屏时吃掉剩余空间,把胶囊顶到列表可见底边; + * - `sticky bottom-3`:内容溢出一屏时把胶囊从内容末尾**上拉**贴住可见底边。 + * 只用 `sticky` 不够——它只能上拉、不能下推,内容不足一屏时元素会停在文档流里(悬空)。 + * + * 用列表内的 flex 项而不是浮层:工作台里 `.project-chat-surface.is-direct-codex > + * .project-chat-conversation` 是纵向 flex,`.project-chat-message-list` 是其中 + * `flex: 1 1 auto` 的唯一滚动项;在列表外套一层定位容器会让列表塌成内容高度, + * 改公共类又会连带 `PlanningChatView`。粘在列表内部零结构改动,两块宿主 + * (工作台侧栏与导出面板)都能拿到。 * * 显隐与文案由调用方决定(距底超过阈值才出现;不跟随时来了新回复就换文案),这里只负责表现。 */ @@ -19,7 +27,7 @@ export function DirectProjectScrollToBottomCapsule({ return (