Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@ function useGatewayAppController() {
viewport: transcriptViewport,
listenerRoot: transcriptScrollAreaRoot,
trackKeys: true,
// 回贴区为 0:只有真正到达底部(8px 容差内)才恢复跟随。默认的 192px
// 回贴区会在滚轮下行进入该区间的那一 tick 直接 pin 到底,读者看到的是
// 正文突然上跳一段(复现页实测单 tick 231px),即"磁吸"。到达底部后向下
// 滚轮、手势落底、指针在底部释放三种路径仍会重新贴底。
config: { reattachZonePx: 0 },
});
// 楼层导航:当前楼层由转写区上报,跳转经 navRef 直达虚拟列表;粘底跟随
// 激活时程序化滚动会被立即拽回底部——跳转前先按「跳入历史」语义解除跟随。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
mergePendingUploadedFiles,
type PendingUploadedFile,
} from "@liveagent/ui/lib/chat/uploadedFiles";
import { useScrollFollow } from "@liveagent/ui/lib/chat-scroll/useScrollFollow";
import { toTrajectoryMessages } from "@liveagent/ui/lib/trajectory/transcriptMessages";
import {
ChatComposerBar,
Expand Down Expand Up @@ -616,41 +617,27 @@ export function GatewayConversationPaneHost(props: GatewayConversationPaneHostPr
if (composerRef.current) pageComposerRef.current = composerRef.current;
});

// ---- 转录滚动跟随:贴底自动跟进,用户上滚即释放,支持一键回底 ------------
const viewportRef = useRef<HTMLDivElement | null>(null);
const followingRef = useRef(true);
const [following, setFollowing] = useState(true);
const detachScrollRef = useRef<(() => void) | null>(null);
const setViewport = useCallback((element: HTMLDivElement | null) => {
detachScrollRef.current?.();
detachScrollRef.current = null;
viewportRef.current = element;
if (!element) return;
element.scrollTop = element.scrollHeight;
const handleScroll = () => {
const nearBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
followingRef.current = nearBottom;
setFollowing(nearBottom);
};
element.addEventListener("scroll", handleScroll, { passive: true });
detachScrollRef.current = () => element.removeEventListener("scroll", handleScroll);
}, []);
useEffect(() => () => detachScrollRef.current?.(), []);
// ---- 转录滚动跟随 -----------------------------------------------------
// 背景 Pane 与主 Pane 共用同一套滚动跟随引擎:主 Pane 的视口由 GatewayApp
// 持有的引擎接管(经 primary.setTranscriptViewport 接线),本地这套只在
// 非主态生效。回贴区为 0:只有真正到达底部才恢复跟随。此前这里手写了一套
// "距底不足 48px 即视为跟随、行数一变就写 scrollTop" 的逻辑,读者停在底部
// 附近时会被每次流式增量吸回底部,而且与桌面端各 Pane 的引擎语义不一致。
const usePrimary = Boolean(isPrimary && primary);
const [paneScrollAreaRoot, setPaneScrollAreaRoot] = useState<HTMLDivElement | null>(null);
const [paneViewport, setPaneViewport] = useState<HTMLDivElement | null>(null);
const { handle: paneFollow, following: paneFollowing } = useScrollFollow({
viewport: paneViewport,
listenerRoot: paneScrollAreaRoot,
enabled: !usePrimary,
config: { reattachZonePx: 0 },
});
// 会话切换后落在最新消息上,与桌面端 ChatTranscript 一致。
// biome-ignore lint/correctness/useExhaustiveDependencies: conversationId 是有意的重置信号,动作由 handle 执行。
useLayoutEffect(() => {
if (!usePrimary) paneFollow.stickToBottom();
}, [conversationId, paneFollow, usePrimary]);
const rowCount = transcript.rows.length;
// biome-ignore lint/correctness/useExhaustiveDependencies: 行数/修订变化时按跟随态贴底,效果体不直接读取它们。
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport || !followingRef.current) return;
viewport.scrollTop = viewport.scrollHeight;
}, [rowCount, transcript.revision]);
const jumpToBottom = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight;
followingRef.current = true;
setFollowing(true);
}, []);
const isViewportFollowing = useCallback(() => followingRef.current, []);

// ---- 每会话模型/用量/进度/审批 -------------------------------------------
const selectedValue = selection
Expand Down Expand Up @@ -738,12 +725,15 @@ export function GatewayConversationPaneHost(props: GatewayConversationPaneHostPr
);
}

const usePrimary = Boolean(isPrimary && primary);
const transcriptFollowing = usePrimary ? (primary?.viewportFollowing ?? following) : following;
const transcriptFollowing = usePrimary
? (primary?.viewportFollowing ?? paneFollowing)
: paneFollowing;
const transcriptIsViewportFollowing =
usePrimary && primary?.isViewportFollowing ? primary.isViewportFollowing : isViewportFollowing;
usePrimary && primary?.isViewportFollowing
? primary.isViewportFollowing
: paneFollow.isFollowing;
const handleJumpToBottom =
usePrimary && primary?.onJumpToBottom ? primary.onJumpToBottom : jumpToBottom;
usePrimary && primary?.onJumpToBottom ? primary.onJumpToBottom : paneFollow.jumpToBottom;
const transcriptTree = (
<GatewayTranscript
conversationId={conversationId}
Expand Down Expand Up @@ -837,11 +827,11 @@ export function GatewayConversationPaneHost(props: GatewayConversationPaneHostPr
) : (
<div className="gateway-transcript-scroll-shell">
<ScrollArea
ref={usePrimary ? primary?.setTranscriptScrollAreaRoot : undefined}
ref={usePrimary ? primary?.setTranscriptScrollAreaRoot : setPaneScrollAreaRoot}
viewportRef={
usePrimary && primary?.setTranscriptViewport
? primary.setTranscriptViewport
: setViewport
: setPaneViewport
}
className="gateway-transcript-scroll"
>
Expand Down
Loading
Loading