功能:DirectProject 对话自动加载更早历史并支持回到底部
- 删掉常驻的「显示更早的对话」按钮,改由触顶与填充视口两条触发自动加载,失败即挂起并留内联重试行 - 新增 useConversationScroll:列表唯一滚动所有权(跟随最新、ResizeObserver 补偿、前插锚点冻结、胶囊显隐) - 新增顶部加载行与底部居中 sticky 胶囊两个表现组件,文案与阈值取自 conversationScrollPolicy - 列表追加 overflow-anchor:none 关掉原生锚定;「有新回复」按最后一轮终态内容指纹判定 - DirectProjectChatView 交出 messagesRef / shouldFollowLatestRef / handleScroll,只传加载状态与回调 Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
+6
-24
@@ -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 &&
|
||||
|
||||
+49
-17
@@ -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
|
||||
|
||||
+54
@@ -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>
|
||||
);
|
||||
}
|
||||
+29
@@ -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>
|
||||
);
|
||||
}
|
||||
+15
@@ -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}`;
|
||||
}
|
||||
|
||||
+269
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user