fix: 流式自动开沙箱分阶段收起侧栏,避免同帧布局卡顿

新增 preOpen 两阶段标记,先错开收起左右侧栏再展开沙箱;智能体详情面板据此提前收起,减少与沙箱展开同帧叠加造成的卡顿。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-26 18:24:06 +08:00
1 parent 90d29ed232
commit 6e9e89a6cb
2 files changed
+96 -16

No files matched your search

@@ -267,8 +267,11 @@ export function AgentDetailPanel({
const artifactsCtx = useArtifactsOptional(); const artifactsCtx = useArtifactsOptional();
const artifactCount = artifactsCtx?.artifacts.length ?? 0; const artifactCount = artifactsCtx?.artifacts.length ?? 0;
const selectedArtifact = artifactsCtx?.selectedArtifact ?? null; const selectedArtifact = artifactsCtx?.selectedArtifact ?? null;
// preOpen:流式自动开沙箱的两阶段标记 —— 沙箱真正展开(open 翻转)前就
// 先收起本面板,让收起布局先落地,避免与沙箱展开同帧叠加造成卡顿。
const artifactPanelVisible = const artifactPanelVisible =
!!artifactsCtx?.open && (!!selectedArtifact || artifactCount > 0); (!!artifactsCtx?.open || !!artifactsCtx?.preOpen) &&
(!!selectedArtifact || artifactCount > 0);
const prevArtifactPanelVisible = useRef(false); const prevArtifactPanelVisible = useRef(false);
useEffect(() => { useEffect(() => {
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current; const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
@@ -2,6 +2,7 @@ import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect,
useRef, useRef,
useState, useState,
type ReactNode, type ReactNode,
@@ -42,6 +43,12 @@ export interface ArtifactsContextType {
open: boolean; open: boolean;
autoOpen: boolean; autoOpen: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
/**
* 流式写文件自动开沙箱的两阶段标记:openStreamedArtifact 先收起两侧侧栏
* (阶段一),等收起布局落定后再真正翻转 open(阶段二)。两阶段之间为
* true —— 右侧智能体信息面板据此提前收起,避免与沙箱展开同帧叠加造成卡顿。
*/
preOpen: boolean;
} }
const ArtifactsContext = createContext<ArtifactsContextType | undefined>( const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
@@ -52,6 +59,13 @@ interface ArtifactsProviderProps {
children: ReactNode; children: ReactNode;
} }
// 流式写文件自动开沙箱的分步延迟:t=0 收起左侧聊天记录侧栏,延迟
// STREAMED_PREOPEN_DELAY_MS 后再收右侧智能体信息面板(两次收起错开、
// 不做「左边收完再收右边」的判断,只给固定延迟,避免收起瞬间同帧叠加
// 卡顿),到 STREAMED_OPEN_DELAY_MS(右侧 300ms 收起动画结束)才展开沙箱。
const STREAMED_PREOPEN_DELAY_MS = 200;
const STREAMED_OPEN_DELAY_MS = 500;
export function ArtifactsProvider({ children }: ArtifactsProviderProps) { export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]); const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => { const setArtifacts = useCallback((next: string[]) => {
@@ -69,14 +83,50 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [open, setOpenState] = useState( const [open, setOpenState] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
); );
const [preOpen, setPreOpen] = useState(false);
const [autoOpen, setAutoOpen] = useState(true); const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null); const suppressedAutoOpenKeyRef = useRef<string | null>(null);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次 // 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。 // Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey); const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
const autoOpenRef = useRef(autoOpen); const autoOpenRef = useRef(autoOpen);
const openStateRef = useRef(open);
const preOpenTimerRef = useRef<number | null>(null);
const openTimerRef = useRef<number | null>(null);
const sidebar = useSidebarSafe(); const sidebar = useSidebarSafe();
const applyOpen = useCallback((next: boolean) => {
openStateRef.current = next;
setOpenState((previous) => (previous === next ? previous : next));
}, []);
// 取消尚未触发的分步自动开沙箱(用户手动关闭 / 切换会话时)。
const cancelPendingStreamedOpen = useCallback(() => {
if (preOpenTimerRef.current !== null) {
window.clearTimeout(preOpenTimerRef.current);
preOpenTimerRef.current = null;
}
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
setPreOpen(false);
}, []);
useEffect(
() => () => {
if (preOpenTimerRef.current !== null) {
window.clearTimeout(preOpenTimerRef.current);
preOpenTimerRef.current = null;
}
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
},
[],
);
const select = useCallback( const select = useCallback(
(artifact: string, autoSelect = false) => { (artifact: string, autoSelect = false) => {
const previewKey = artifactPreviewKey(artifact); const previewKey = artifactPreviewKey(artifact);
@@ -104,13 +154,33 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey)); setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
setAutoSelect((previous) => (previous ? previous : true)); setAutoSelect((previous) => (previous ? previous : true));
setAutoOpen((previous) => (previous ? previous : true)); setAutoOpen((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? previous : true)); // 阶段一(t=0):立即收起左侧聊天记录侧栏。
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") { if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false); sidebar?.setOpen(false);
} }
if (openStateRef.current) {
// 沙箱已展开(如连续写多个文件):只需切换选中文件,无需分步。
cancelPendingStreamedOpen();
return true;
}
if (openTimerRef.current === null) {
// 阶段二(t=+200ms):延迟固定时长再置 preOpen,右侧智能体信息
// 面板此时才开始收起 —— 与左侧错开,避免两次收起同帧叠加卡顿。
preOpenTimerRef.current = window.setTimeout(() => {
preOpenTimerRef.current = null;
setPreOpen(true);
}, STREAMED_PREOPEN_DELAY_MS);
// 阶段三(t=+500ms,右侧 300ms 收起动画结束):翻转 open 展开
// 沙箱,把「聊天列变窄 + 沙箱内容挂载」与侧栏收起彻底分帧。
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
applyOpen(true);
setPreOpen(false);
}, STREAMED_OPEN_DELAY_MS);
}
return true; return true;
}, },
[sidebar], [applyOpen, cancelPendingStreamedOpen, sidebar],
); );
const deselect = useCallback(() => { const deselect = useCallback(() => {
@@ -118,27 +188,34 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
setSelectedArtifact((previous) => (previous === null ? previous : null)); setSelectedArtifact((previous) => (previous === null ? previous : null));
setSelectedArtifactKey((previous) => (previous === null ? previous : null)); setSelectedArtifactKey((previous) => (previous === null ? previous : null));
setAutoSelect((previous) => (previous ? previous : true)); setAutoSelect((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? false : previous)); cancelPendingStreamedOpen();
applyOpen(false);
suppressedAutoOpenKeyRef.current = null; suppressedAutoOpenKeyRef.current = null;
}, []); }, [applyOpen, cancelPendingStreamedOpen]);
const setOpen = useCallback((isOpen: boolean) => { const setOpen = useCallback(
if (!isOpen && autoOpenRef.current) { (isOpen: boolean) => {
autoOpenRef.current = false; if (!isOpen) {
setAutoOpen((previous) => (previous ? false : previous)); // 两阶段等待期间用户手动关闭:取消尚未触发的自动展开。
setAutoSelect((previous) => (previous ? false : previous)); cancelPendingStreamedOpen();
} if (autoOpenRef.current) {
if (!isOpen) { autoOpenRef.current = false;
suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current; setAutoOpen((previous) => (previous ? false : previous));
} setAutoSelect((previous) => (previous ? false : previous));
setOpenState((previous) => (previous === isOpen ? previous : isOpen)); }
}, []); suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
}
applyOpen(isOpen);
},
[applyOpen, cancelPendingStreamedOpen],
);
const value: ArtifactsContextType = { const value: ArtifactsContextType = {
artifacts, artifacts,
setArtifacts, setArtifacts,
open, open,
preOpen,
autoOpen, autoOpen,
autoSelect, autoSelect,
setOpen, setOpen,