Merge pull request 'Fix/chat history style' (#662) from fix/chat-history-style into master
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Reviewed-on: #662
This commit was merged in pull request #662.
This commit is contained in:
@@ -11892,7 +11892,10 @@ button.design-workspace-tree__entry:hover,
|
||||
left: 0;
|
||||
margin: 16px;
|
||||
max-height: none;
|
||||
z-index: 1;
|
||||
/* 抬到列表里「回到底部」胶囊(z-index: 10)之上:输入盒自己是层叠上下文,模型 / 附件
|
||||
菜单的 z-index 只在盒内有效,盒子不抬高就会被胶囊盖住。30 仍低于拖拽落点(60)与
|
||||
设置弹窗(240)这两个整屏浮层,它们照旧盖在输入盒之上。 */
|
||||
z-index: 30;
|
||||
/* 盒内内边距四边同值:文字/光标的左内缩与上内缩必须相等,
|
||||
否则点进输入框时(`.resource-reference-input:focus-within` 会画出焦点环)
|
||||
会明显看出文字离左边近、离上边远。 */
|
||||
@@ -13401,10 +13404,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 +13417,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;
|
||||
|
||||
+12
-4
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
className="sticky bottom-3 z-10 mx-auto mt-1 flex w-fit items-center gap-1.5 rounded-full border border-[color:var(--platform-subpanel-border)] bg-[var(--platform-neutral-bg)] px-3 py-1.5 text-xs font-medium text-[color:var(--platform-text-strong)] shadow-[0_6px_18px_rgba(24,32,47,0.16)] backdrop-blur"
|
||||
className="project-chat-scroll-to-bottom sticky bottom-3 z-10 mx-auto flex w-fit items-center gap-1.5 rounded-full border border-[color:var(--platform-subpanel-border)] bg-[var(--platform-neutral-bg)] px-3 py-1.5 text-xs font-medium text-[color:var(--platform-text-strong)] shadow-[0_6px_18px_rgba(24,32,47,0.16)] backdrop-blur"
|
||||
onClick={onClick}
|
||||
>
|
||||
<ArrowDown size={13} aria-hidden="true" />
|
||||
|
||||
+9
@@ -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 = '重试';
|
||||
|
||||
+34
-3
@@ -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`,但内容**变短**到一屏以内时不会有滚动事件:回合收口把
|
||||
* 过程折进收起的 `<details>`、历史加载行卸载,都会让 `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({
|
||||
// 锚点还原失败(块已经不在列表里,或收口后被折进收起的 `<details>`、没有布局盒算不出偏移):
|
||||
// 拿当前位置重新起锚,别用过期基准写 `scrollTop`;只是这次不自动对齐,不会回跳。
|
||||
preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
|
||||
}, []);
|
||||
}, [syncBottomFromLayout]);
|
||||
|
||||
// 换会话:列表容器不重挂载,滚动所有权必须显式复位,否则上一个会话的「已离开底部」
|
||||
// 会漏到新会话。必须先于下面那条「内容变化」的 effect:终态指纹要先同步成新会话的内容,
|
||||
@@ -214,8 +239,11 @@ export function useConversationScroll({
|
||||
scrollListToBottom(listRef.current, 'auto');
|
||||
return;
|
||||
}
|
||||
// 回合收口会把过程折进收起的 `<details>`,内容可能就此塌回一屏以内:先按真实几何复核,
|
||||
// 已经滚无可滚就不再点亮「有新回复」(那时列表里根本没有可去的底部)。
|
||||
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]);
|
||||
|
||||
// 程序化滚动被用户接手(滚轮 / 触摸 / 键盘)时立刻交还控制权。
|
||||
// 动画被打断后浏览器不会再发贴底滚动事件,标记不交还就会永远挂着,后续跟随与补偿全失效。
|
||||
|
||||
+14
-8
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5969,7 +5969,9 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(composerRule?.[1]).not.toBeUndefined();
|
||||
expect(composerRule?.[1]).toContain('position: relative;');
|
||||
expect(composerRule?.[1]).toContain('gap: 8px;');
|
||||
expect(composerRule?.[1]).toContain('z-index: 1;');
|
||||
// 输入盒必须高于列表里的「回到底部」胶囊(胶囊 z-10),盒内向上展开的模型 / 附件菜单
|
||||
// 才能盖住它;大小关系由 chatDialogFrameLayout.test.ts 的层叠用例守卫,这里钉字面值。
|
||||
expect(composerRule?.[1]).toContain('z-index: 30;');
|
||||
// 四边留白统一 16px、盒内内边距四边同为 12px(文字左内缩必须等于上内缩);
|
||||
// 不允许再出现 `8px 12px 10px` / `10px 12px` 这类「左右一个值、上下另一个值」的写法。
|
||||
expect(composerRule?.[1]).toContain('margin: 16px;');
|
||||
|
||||
@@ -22,6 +22,10 @@ const INPUT_SOURCE_PATH = repoPath(
|
||||
const CHAT_COMPOSER_CONTROLS_PATH = repoPath(
|
||||
'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx',
|
||||
);
|
||||
/** 「回到底部」胶囊:z-index 写在 Tailwind 类上(`z-10`),styles.css 的层叠求值看不到。 */
|
||||
const CAPSULE_PATH = repoPath(
|
||||
'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx',
|
||||
);
|
||||
|
||||
function lengthPx(rawValue: string, label: string): number {
|
||||
const value = rawValue.trim();
|
||||
@@ -329,10 +333,33 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文
|
||||
);
|
||||
expect(declaration(composer, 'border-radius')).toBe('14px');
|
||||
|
||||
// 对话内容左右留白 16px,消息之间 14px。
|
||||
// 对话内容左右留白 16px。列表现在是 flex 列容器,`gap` 必须显式归 0:基础规则里那条
|
||||
// `gap: 14px` 在块级容器上一直没生效,改成 flex 后若不归零,会和子项的 `margin-top`
|
||||
// 叠成双倍间距;子项间距仍由 `> * + *` 的 margin-top 提供。
|
||||
expect(paddingBox(list).left).toBe(16);
|
||||
expect(paddingBox(list).right).toBe(16);
|
||||
expect(declaration(list, 'gap')).toBe('14px');
|
||||
expect(declaration(list, 'gap')).toBe('0');
|
||||
});
|
||||
|
||||
it('「回到底部」胶囊不盖住输入盒弹层:胶囊层叠低于输入盒(宽屏)', () => {
|
||||
// 胶囊是列表内的 sticky 浮标(Tailwind `z-10`),模型菜单(盒内 z-index: 20)与附件菜单
|
||||
// (盒内 z-index: 3)都从输入盒向上展开、会盖到列表底部。输入盒自己是 `position: relative`
|
||||
// + z-index 的层叠上下文,盒内菜单的 z-index 只在盒内有效,所以要让**盒子**高于胶囊,
|
||||
// 菜单才能盖住胶囊;否则重叠处命中的会是胶囊(真机 hit-test 实测:盒 z-index:1 时命中胶囊)。
|
||||
const capsuleClassName =
|
||||
/className="([^"]*)"/u.exec(readFileSync(CAPSULE_PATH, 'utf8'))?.[1] ??
|
||||
'';
|
||||
const capsuleZIndex = Number(/\bz-(\d+)\b/u.exec(capsuleClassName)?.[1]);
|
||||
const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN);
|
||||
const composerZIndex = Number(declaration(composer, 'z-index'));
|
||||
expect(Number.isFinite(capsuleZIndex), '胶囊必须带 Tailwind z-* 类').toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
Number.isFinite(composerZIndex),
|
||||
'输入盒必须声明 z-index 来建立层叠上下文',
|
||||
).toBe(true);
|
||||
expect(composerZIndex).toBeGreaterThan(capsuleZIndex);
|
||||
});
|
||||
|
||||
it('消息列表底部不留给浮层的空白,滚动到底不会多出一段空白(宽屏)', () => {
|
||||
|
||||
@@ -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 回流;立即卸载会把加载行一帧内拆了又挂。全是工具调用时更明显——工具组折在收起的 `<details>` 里,每页几乎不增加可见高度,视口一直填不满,加载行就在「页与页之间」忽隐忽现。300ms 的隐藏延迟让连续加载之间的小空隙不卸载,真正结束(超时后仍为假)照常卸载。
|
||||
- 失败:挂起自动加载,列表顶部保留一行内联错误行——`role="alert"` 只包住「加载更早对话失败」文案本身,重试是可聚焦按钮、留在 live region 之外(assertive + atomic 的 live region 里不放交互控件);**不自动重试**,只有点重试(或切换项目)才重新开始;重试成功后错误行消失。
|
||||
- 一次加载与它所属的**世代**绑定:切换项目或新起一次读取都推进世代号(`historyLoadTokenRef`),旧世代落地时整段失效——不合并条目、不写游标、不关加载态。只比项目路径不够:A→B→A 之后在飞的旧读取又落回同一个路径,原守卫放行,会把新一代的加载行与 `historyLoadingRef` 这道并发闸门一起改掉。换项目的推进放在**渲染期**(与 `projectPathRef` 同一处),不放在复位 effect 里:passive effect 走宏任务、promise 续体走微任务,旧读取可能在「切换提交完成、复位 effect 还没跑」的窗口里落地,那时世代号还是旧的,守卫会放行。
|
||||
|
||||
@@ -37,8 +38,9 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme
|
||||
|
||||
### 3. 回到底部胶囊
|
||||
|
||||
- 列表底部居中的悬浮胶囊(`sticky`),跟着滚动容器走、不随内容滚走。
|
||||
- 列表底部居中的悬浮胶囊,**位置基于列表可见底边**:列表是 flex 列容器,胶囊是最后一个 flex 项,`margin-top: auto` 在内容不足一屏时把它顶到可见底边,内容溢出一屏时 auto 归零、再由 `sticky bottom-3` 上拉贴底。只用 `sticky` 不够——它只能把元素**上拉**、不能下推,内容不足一屏时胶囊会停在文档流里(悬空),所以必须由 flex 的 auto margin 兜住「不足一屏」这一半。
|
||||
- 距底部超过 48px 时出现,文案「回到底部」;用户不跟随时来了新的终态内容就改成「有新回复 · 回到底部」。
|
||||
- 贴底判定不能只认 `onScroll`:内容**变短**到一屏以内时不会再有任何滚动事件(回合收口把过程折进收起的 `<details>`、历史加载行卸载),`atBottom` 会停在离开底部时的假值,胶囊就永远挂在一个滚不动的列表上(短历史一屏显示完却还悬着「回到底部」)。因此每次布局变化(`ResizeObserver`)、子元素增删(`MutationObserver`)与内容变化后都按真实几何复核一次 `isNearBottom`,复核为真即收起胶囊并清掉「有新回复」。
|
||||
- 点击:平滑滚到底部 + 恢复跟随最新 + 清除「有新回复」,随后按钮自行消失。
|
||||
- 平滑滚动期间滚动位置归这次程序化滚动所有:滚动事件不再翻转「跟随最新」,布局补偿也不写 `scrollTop`(写一次就会取消动画并把画面拉回原处,表现为「点了只下去一屏、到不了底」)。滚到贴底阈值即交还控制权;用户中途用滚轮 / 触摸 / 键盘打断则立刻交还,不会卡住后续跟随。
|
||||
- 动画期间内容变高(流式正文、图片撑开)时,点击瞬间记下的 `scrollHeight` 已经不是底部:补偿不写 `scrollTop`,而是把动画目标重新对准新的底部。否则动画停在旧目标上、等不到「贴底」那次滚动事件,`programmaticScrollRef` 不会交还——跟随与布局补偿整段挂起,而 `scrollToBottom` 已把胶囊按「已贴底」隐掉,用户停在底部之上却没有任何指示与自动跟随。
|
||||
@@ -67,7 +69,7 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme
|
||||
## 备选方案与取舍
|
||||
|
||||
1. **保留按钮 + 只加自动加载**:加载中仍靠按钮做唯一反馈,且删掉按钮后失败路径没有补救入口;按钮本身与滚动自动加载重复。
|
||||
2. **在列表外面套一层 viewport 做浮层定位**:`PlanningChatView` 共用同一套容器规则,且工作台里 `.project-chat-conversation` 是 `display: block` + `height: 100%` 几何,套一层就会让 `height: 100%` 的列表塌成内容高度;改公共类会连带策划对话。改用列表内的 `sticky` 胶囊,零结构改动。
|
||||
2. **在列表外面套一层 viewport 做浮层定位**:`PlanningChatView` 共用同一套容器规则,且工作台里 `.project-chat-surface.is-direct-codex > .project-chat-conversation` 是纵向 flex、`.project-chat-message-list` 是其中 `flex: 1 1 auto` 的唯一滚动项,套一层定位容器会让列表塌成内容高度;改公共类会连带策划对话。改用列表内的 flex 项(`margin-top: auto` + `sticky`),零结构改动。
|
||||
3. **只依赖原生 CSS scroll anchoring**:前插能免费对齐,但做不到「跟随时展开要贴底」,也无法在加载期间冻结同一套锚点;因此显式补偿 + 关闭原生锚定。
|
||||
4. **展开后总是把正文滚进视口**:对正文比视口矮的折叠块会把画面大幅上移,打断正在读历史的用户;采用「跟随时贴底 / 否则冻结折叠头 + 只滚到刚好露出新展开正文的最小位移」。
|
||||
5. **平滑滚动期间照常处理滚动事件与布局补偿**:程序化滚动会被应用自己的补偿打断(第一次写 `scrollTop` 即取消动画),用户点了「回到底部」也停在半路;因此改为滚动期间冻结这两条路径。
|
||||
@@ -80,6 +82,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 端到端。
|
||||
|
||||
Reference in New Issue
Block a user