功能:DirectProject 对话自动加载更早历史并支持回到底部

- 删掉常驻的「显示更早的对话」按钮,改由触顶与填充视口两条触发自动加载,失败即挂起并留内联重试行
- 新增 useConversationScroll:列表唯一滚动所有权(跟随最新、ResizeObserver 补偿、前插锚点冻结、胶囊显隐)
- 新增顶部加载行与底部居中 sticky 胶囊两个表现组件,文案与阈值取自 conversationScrollPolicy
- 列表追加 overflow-anchor:none 关掉原生锚定;「有新回复」按最后一轮终态内容指纹判定
- DirectProjectChatView 交出 messagesRef / shouldFollowLatestRef / handleScroll,只传加载状态与回调

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 17:54:03 +08:00
parent 1653a2be6e
commit 0aa9dad3c7
6 changed files with 422 additions and 41 deletions
@@ -1,15 +1,12 @@
import type { UIEventHandler } from 'react';
import type { Ref } from 'react';
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD } from '../../../app/constants';
import { claimInitialTurnForPage } from '../../../app/initialTurnClaims';
import type { PendingUiConfirmation } from '../../../app/types';
import { projectNameFromPath } from '../../../features/agent-runtime';
@@ -106,8 +103,6 @@ export function DirectProjectChatView({
}: DirectProjectChatViewProps) {
const { assets, projectId, refresh, versions } =
useDirectProjectManifest(projectPath);
const messagesRef = useRef<HTMLDivElement | null>(null);
const shouldFollowLatestRef = useRef(true);
const [runtimeNotice, setRuntimeNotice] = useState('');
const [settingsOpen, setSettingsOpen] = useState(false);
const [approvalOpen, setApprovalOpen] = useState(false);
@@ -137,8 +132,11 @@ export function DirectProjectChatView({
directEntries,
directTurnRunning,
directTurnStartedAt,
historyError,
historyHasMore,
historyLoading,
loadEarlierHistory,
retryEarlierHistory,
localMessages,
pendingTurns,
startInitialTurn,
@@ -197,7 +195,6 @@ export function DirectProjectChatView({
initialTurn.content,
directCodexConversationMessageId(clientTurnId, 'user'),
);
shouldFollowLatestRef.current = true;
startInitialTurn({
clientTurnId,
...(initialTurn.creationType
@@ -209,28 +206,12 @@ export function DirectProjectChatView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [turnStatus.displayBusy, initialTurn, projectId, projectPath]);
useEffect(() => {
if (!shouldFollowLatestRef.current) return;
const list = messagesRef.current;
if (list) list.scrollTop = list.scrollHeight;
}, [directEntries, localMessages]);
useImperativeHandle(ref, () => ({
announce: (text: string) => {
appendLocalMessage({ role: 'assistant', text, updatedAt: Date.now() });
},
}));
const handleScroll: UIEventHandler<HTMLDivElement> = (event) => {
const list = event.currentTarget;
shouldFollowLatestRef.current =
list.scrollHeight - list.scrollTop - list.clientHeight <=
AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD;
if (historyHasMore && list.scrollTop <= 24) {
void loadEarlierHistory();
}
};
return (
<section
className="project-chat-surface is-direct-codex"
@@ -245,12 +226,13 @@ export function DirectProjectChatView({
/>
<DirectProjectConversation
turns={directTurns}
messagesRef={messagesRef}
historyHasMore={historyHasMore}
historyLoading={historyLoading}
historyError={historyError}
turnInFlight={turnStatus.displayBusy}
activeTurnStartedAt={activeTurnStartedAt}
onLoadEarlierHistory={() => void loadEarlierHistory()}
onScroll={handleScroll}
onRetryEarlierHistory={() => void retryEarlierHistory()}
/>
{pendingConfirmation &&
onConfirmConfirmation &&
@@ -1,28 +1,40 @@
import type { RefObject, UIEventHandler } from 'react';
import { AgentMessageContent } from '../../../../../../../../packages/shared/src/components/AgentMessageContent';
import { useLiveNow } from '../../../../../features/project-workspace/useLiveNow';
import type { DirectChatTurn } from '../../conversation/directTurnPresentation';
import { formatTurnDuration } from '../ToolCallGroup/toolCallGroupPresentation';
import {
DirectProjectHistoryErrorRow,
DirectProjectHistoryLoadingRow,
} from './DirectProjectHistoryRow';
import { DirectProjectScrollToBottomCapsule } from './DirectProjectScrollToBottomCapsule';
import { DirectProjectTurn } from './DirectProjectTurn';
import { useConversationScroll } from './useConversationScroll';
/**
* 会话区:回合列表、更早历史入口和运行中过程卡。
* 会话区:回合列表、更早历史的加载 / 失败行与运行中过程卡。
*
* **滚动归这一层所有**:贴底跟随、更早历史的自动加载、前插锚点与回到底部胶囊都在
* `useConversationScroll` 里,视图不再自己持 `messagesRef` / `shouldFollowLatestRef` /
* `handleScroll`——三套补偿写同一份 `scrollTop`,分开持有必然互相覆盖。
*
* 回合来自 DirectProject 自己的投影;这里不读历史、不发回合,只把容器给的状态渲染出来。
*/
export function DirectProjectConversation({
turns,
messagesRef,
historyHasMore,
historyLoading,
historyError,
turnInFlight,
activeTurnStartedAt,
onLoadEarlierHistory,
onScroll,
onRetryEarlierHistory,
}: {
turns: DirectChatTurn[];
messagesRef: RefObject<HTMLDivElement | null>;
historyHasMore: boolean;
/** 更早历史正在读:驱动顶部加载行(延迟 150ms 才挂载)。 */
historyLoading: boolean;
/** 更早历史读取失败:非空即挂起自动加载,只留内联错误行的重试。 */
historyError: string | null;
/**
* 这一轮在飞吗:`DirectProjectTurnStatus.displayBusy`(本地命令在飞 ∪ 原生已确认在跑)。
*
@@ -33,28 +45,48 @@ export function DirectProjectConversation({
turnInFlight: boolean;
activeTurnStartedAt: number;
onLoadEarlierHistory: () => void;
onScroll: UIEventHandler<HTMLDivElement>;
onRetryEarlierHistory: () => void;
}) {
const {
listRef,
onScroll,
onToggleCapture,
scrollToBottom,
showScrollToBottom,
scrollToBottomLabel,
showHistoryLoading,
} = useConversationScroll({
turns,
historyHasMore,
historyLoading,
historyError,
turnInFlight,
onLoadEarlierHistory,
});
return (
<>
{/* `overflow-anchor: none`:关掉浏览器原生 scroll anchoring,前插与展开的补偿只走
`useConversationScroll` 这一份,避免两套补偿在同一帧里互相抵消。 */}
<div
ref={messagesRef}
className="message-list project-chat-message-list"
ref={listRef}
className="message-list project-chat-message-list [overflow-anchor:none]"
aria-label="陶泥儿消息"
onScroll={onScroll}
onClickCapture={onToggleCapture}
>
{historyHasMore ? (
<button
type="button"
className="message-history-more"
onClick={onLoadEarlierHistory}
>
显示更早的对话
</button>
{showHistoryLoading ? <DirectProjectHistoryLoadingRow /> : null}
{historyError ? (
<DirectProjectHistoryErrorRow onRetry={onRetryEarlierHistory} />
) : null}
{turns.map((turn) => (
<DirectProjectTurn key={turn.key} turn={turn} />
))}
{showScrollToBottom ? (
<DirectProjectScrollToBottomCapsule
label={scrollToBottomLabel}
onClick={scrollToBottom}
/>
) : null}
</div>
{turnInFlight ? (
<AgentMessageContent
@@ -0,0 +1,54 @@
import { Loader2 } from 'lucide-react';
import {
HISTORY_ERROR_TEXT,
HISTORY_LOADING_TEXT,
HISTORY_RETRY_TEXT,
} from './conversationScrollPolicy';
/**
* 列表顶部的两行历史状态条(加载中 / 加载失败)。
*
* 它们都排在**最旧一条回合之上**——内容将出现的位置,而不是浮在视口上——所以不影响下面消息的
* 可读性;前插期间的 `scrollTop` 补偿由 `useConversationScroll` 负责,这两行本身只管一层皮。
*
* 按钮删掉之后,「加载更早对话失败」这行是用户唯一还能自己补救的入口:它一直留到重试成功,
* 不自动消失。
*/
/** 加载更早历史时的旋转圈。按需挂载,是否显示由 `useDelayedFlag` 决定。 */
export function DirectProjectHistoryLoadingRow() {
return (
<p
className="flex items-center justify-center gap-1.5 py-2 text-xs text-[color:var(--platform-text-muted)]"
role="status"
aria-live="polite"
>
<Loader2 className="animate-spin" size={14} aria-hidden="true" />
{HISTORY_LOADING_TEXT}
</p>
);
}
/** 自动加载挂起后的内联错误行:重试是唯一的手动出路,成功前一直留在列表顶部。 */
export function DirectProjectHistoryErrorRow({
onRetry,
}: {
onRetry: () => void;
}) {
return (
<p
className="flex items-center justify-center gap-2 py-2 text-xs text-[color:var(--platform-button-danger-text)]"
role="alert"
>
{HISTORY_ERROR_TEXT}
<button
type="button"
className="rounded-full border border-[color:var(--platform-subpanel-border)] px-2 py-0.5 text-xs text-[color:var(--platform-text-strong)] transition hover:bg-[var(--platform-neutral-bg)]"
onClick={onRetry}
>
{HISTORY_RETRY_TEXT}
</button>
</p>
);
}
@@ -0,0 +1,29 @@
import { ArrowDown } from 'lucide-react';
/**
* 底部居中的「回到底部」胶囊。
*
* 用 `sticky bottom-*` 而不是浮层:工作台里 `.project-chat-conversation` 是 `display: block`
* 加 `height: 100%` 的几何,在列表外套一层定位容器会把列表的 `height: 100%` 塌成内容高度;
* 粘在列表内部的胶囊零结构改动,两块宿主(工作台侧栏与独立页面)都能拿到。
*
* 显隐与文案由调用方决定(距底超过阈值才出现;不跟随时来了新回复就换文案),这里只负责表现。
*/
export function DirectProjectScrollToBottomCapsule({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
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"
onClick={onClick}
>
<ArrowDown size={13} aria-hidden="true" />
{label}
</button>
);
}
@@ -112,3 +112,18 @@ export function shouldFillViewport(
export function scrollToBottomLabel(hasNewReply: boolean): string {
return hasNewReply ? SCROLL_TO_BOTTOM_UNREAD_TEXT : SCROLL_TO_BOTTOM_TEXT;
}
/**
* 「有新回复」的判据:最后一轮的**终态内容**(用户气泡 + 助手最终答复)指纹。
*
* 只取最后一轮的数量,不取全部回合:更早历史是往前插的,用全量指纹会把「加载更早历史成功」
* 误报成「有新回复」。同一轮里过程块继续增长也不算——用户要的是"有没有新内容可说",
* 不是"这一轮跑了多久"。
*/
export function terminalContentSignature(
turns: readonly { key: string; users: unknown[]; finals: unknown[] }[],
): string {
const last = turns[turns.length - 1];
if (!last) return '';
return `${last.key}:${last.users.length}:${last.finals.length}`;
}
@@ -0,0 +1,269 @@
import {
type MouseEventHandler,
type RefObject,
type UIEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type { DirectChatTurn } from '../../conversation/directTurnPresentation';
import {
type ConversationTurnAnchor,
readTopVisibleTurnAnchor,
restoreTurnAnchor,
} from './conversationScrollAnchor';
import {
HISTORY_LOADING_INDICATOR_DELAY_MS,
isNearBottom,
readConversationListMetrics,
scrollToBottomLabel,
shouldFillViewport,
shouldLoadEarlierOnScroll,
terminalContentSignature,
} from './conversationScrollPolicy';
import {
type ConversationToggleFold,
freezeToggleHead,
resolveToggleHead,
} from './conversationToggleReveal';
import { useDelayedFlag } from './useDelayedFlag';
/**
* 会话列表的滚动所有权:跟随最新、更早历史自动加载、前插锚定、折叠补偿与回到底部胶囊。
*
* 这一层只碰列表这一个滚动容器,读的几何全部来自真实布局;所有判据(阈值、文案、合并门)
* 都在 `conversationScrollPolicy.ts` 的纯函数里,策略改动不需要动这里的状态机。契约见
* `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`。
*
* 为什么集中在一处:贴底跟随、前插保锚点、展开补偿是同一份 `scrollTop` 上的三套补偿,
* 分散到视图里就会互相覆盖(同一帧里谁后写谁赢,表现为间歇性跳动)。
*/
export type ConversationScrollOptions = {
/** 已渲染的回合投影:内容变化既驱动贴底,也驱动「有新回复」。 */
turns: readonly DirectChatTurn[];
historyHasMore: boolean;
historyLoading: boolean;
historyError: string | null;
/** 这一轮在飞(`DirectProjectTurnStatus.displayBusy`):新回合开始要强制回到最新。 */
turnInFlight: boolean;
onLoadEarlierHistory: () => void;
};
export type ConversationScrollBinding = {
listRef: RefObject<HTMLDivElement | null>;
onScroll: UIEventHandler<HTMLDivElement>;
/** 点击捕获:记录折叠头展开前的屏幕位置,供布局变化后的补偿用。 */
onToggleCapture: MouseEventHandler<HTMLDivElement>;
scrollToBottom: () => void;
showScrollToBottom: boolean;
scrollToBottomLabel: string;
/** 延迟 150ms 后的加载态:直接挂载会在本地瞬时读取时闪一下。 */
showHistoryLoading: boolean;
};
function scrollListToBottom(
list: HTMLDivElement | null,
behavior: ScrollBehavior,
): void {
if (!list) return;
if (behavior === 'smooth' && typeof list.scrollTo === 'function') {
list.scrollTo({ top: list.scrollHeight, behavior });
return;
}
list.scrollTop = list.scrollHeight;
}
export function useConversationScroll({
turns,
historyHasMore,
historyLoading,
historyError,
turnInFlight,
onLoadEarlierHistory,
}: ConversationScrollOptions): ConversationScrollBinding {
const listRef = useRef<HTMLDivElement | null>(null);
/** 跟随时任何高度变化都贴底;用户离开底部(或点胶囊)即翻转。 */
const followLatestRef = useRef(true);
const [atBottom, setAtBottom] = useState(true);
const [hasNewReply, setHasNewReply] = useState(false);
/** 前插时要保住的顶部可见回合;每次滚动与补偿后刷新。 */
const preserveAnchorRef = useRef<ConversationTurnAnchor | null>(null);
/** 刚刚展开 / 收起的折叠头;只被下一次布局补偿消费一次。 */
const foldRef = useRef<ConversationToggleFold | null>(null);
const turnInFlightRef = useRef(turnInFlight);
const terminalSignatureRef = useRef(terminalContentSignature(turns));
/**
* 更早历史是否正在读:加载期间锚点保持冻结——加载行挂载、历史合并这些回调都还原同一个
* 锚点,加载结束后的下一次补偿才刷新(见 ADR 第 2 条),否则两次补偿会各按各的基准还原。
*/
const historyLoadingRef = useRef(historyLoading);
useEffect(() => {
historyLoadingRef.current = historyLoading;
}, [historyLoading]);
const showHistoryLoading = useDelayedFlag(
historyLoading,
HISTORY_LOADING_INDICATOR_DELAY_MS,
);
/**
* 加载入口过一手 ref:调用方(视图)每次渲染都会给一个新函数,直接进依赖数组会让填充视口的
* 判据每次渲染都重跑一遍;这里只认最后一次给进来的那一个。
*/
const loadEarlierRef = useRef(onLoadEarlierHistory);
useEffect(() => {
loadEarlierRef.current = onLoadEarlierHistory;
}, [onLoadEarlierHistory]);
const loadEarlier = useCallback(() => {
loadEarlierRef.current();
}, []);
/**
* 布局变化后的补偿:跟随时贴底;否则优先冻结刚展开的折叠头,再退到前插锚点。
*
* 顺序不能颠倒:展开的补偿比锚点更精确(锚点只保证"某条回合"不动,冻结保证"你点的那个头"
* 不动),而锚点还原是前插场景唯一能用的手段。
*/
const compensateLayout = useCallback(() => {
const list = listRef.current;
if (!list) return;
if (followLatestRef.current) {
scrollListToBottom(list, 'auto');
return;
}
const fold = foldRef.current;
if (fold) {
foldRef.current = null;
if (freezeToggleHead(list, fold)) {
// 补偿后刷新锚点:下一个回调(一次展开可能触发多次)落到同一条回合上,不会再动。
preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
return;
}
}
const anchor = preserveAnchorRef.current;
if (!anchor) return;
if (restoreTurnAnchor(list, anchor)) {
// 加载期间不刷新:整段加载里的每次补偿都还原同一个锚点,加载结束后再重起一个。
if (!historyLoadingRef.current) {
preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
}
return;
}
// 锚点对应的块已经不在了(折叠态换过结构):拿当前位置重新起锚,别用过期偏移。
preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
}, []);
// 内容变化:跟随时贴底;不跟随时只在**终态内容**增加时点亮「有新回复」。
useEffect(() => {
const signature = terminalContentSignature(turns);
const gainedTerminalContent = signature !== terminalSignatureRef.current;
terminalSignatureRef.current = signature;
if (followLatestRef.current) {
scrollListToBottom(listRef.current, 'auto');
return;
}
if (gainedTerminalContent) setHasNewReply(true);
}, [turns]);
// 观察列表的直接子元素:展开 / 收起、流式正文增长、图片撑高、历史前插都只有这一个入口。
// 子元素增删(回合追加、加载行挂卸)由 MutationObserver 触发重订阅。
useEffect(() => {
const list = listRef.current;
if (!list || typeof ResizeObserver === 'undefined') return undefined;
let observer: ResizeObserver | null = null;
const subscribe = () => {
observer?.disconnect();
observer = new ResizeObserver(() => compensateLayout());
for (const child of Array.from(list.children)) {
observer.observe(child);
}
};
subscribe();
const mutations = new MutationObserver(subscribe);
mutations.observe(list, { childList: true });
return () => {
mutations.disconnect();
observer?.disconnect();
};
}, [compensateLayout]);
// 新一回合开始(假 → 真)强制恢复跟随:用户上一轮跑完时停在中途,这一轮不该再让他手动滚。
useEffect(() => {
const started = turnInFlight && !turnInFlightRef.current;
turnInFlightRef.current = turnInFlight;
if (!started) return;
followLatestRef.current = true;
setAtBottom(true);
setHasNewReply(false);
scrollListToBottom(listRef.current, 'auto');
}, [turnInFlight]);
// 填充视口:首帧之后内容还没铺满,就继续往前拉,直到铺满或没有更早历史。
// 依赖 `turns` 而不是定时器:每次加载落一批新回合就再判一次,同一条链路自己收敛。
useEffect(() => {
const list = listRef.current;
if (!list) return;
const metrics = readConversationListMetrics(list);
if (
!shouldFillViewport(metrics, {
historyHasMore,
historyLoading,
historyError,
})
) {
return;
}
loadEarlier();
}, [historyError, historyHasMore, historyLoading, loadEarlier, turns]);
const onScroll: UIEventHandler<HTMLDivElement> = (event) => {
const list = event.currentTarget;
const metrics = readConversationListMetrics(list);
const nearBottom = isNearBottom(metrics);
followLatestRef.current = nearBottom;
setAtBottom(nearBottom);
if (nearBottom) setHasNewReply(false);
else preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
if (
shouldLoadEarlierOnScroll(metrics, {
historyHasMore,
historyLoading,
historyError,
})
) {
loadEarlier();
}
};
const onToggleCapture: MouseEventHandler<HTMLDivElement> = (event) => {
const list = listRef.current;
const head = resolveToggleHead(event.target as Element | null);
if (!list || !head || !list.contains(head)) return;
foldRef.current = { head, headTop: head.getBoundingClientRect().top };
};
const scrollToBottom = useCallback(() => {
followLatestRef.current = true;
setAtBottom(true);
setHasNewReply(false);
scrollListToBottom(listRef.current, 'smooth');
}, []);
return {
listRef,
onScroll,
onToggleCapture,
scrollToBottom,
showScrollToBottom: !atBottom,
scrollToBottomLabel: scrollToBottomLabel(hasNewReply),
showHistoryLoading,
};
}