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
+154 -74

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;
@@ -1,13 +1,14 @@
import { import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect,
useRef, useRef,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useSidebarSafe } from "@/components/ui/sidebar"; import { useSidebarSafe } from "@/components/ui/sidebar";
import { env } from "@/env"; import { env } from "@/env";
export function artifactPreviewKey(artifact: string): string { export function artifactPreviewKey(artifact: string): string {
@@ -26,11 +27,11 @@ export function artifactPreviewKey(artifact: string): string {
} }
return artifact; return artifact;
} }
export interface ArtifactsContextType { export interface ArtifactsContextType {
artifacts: string[]; artifacts: string[];
setArtifacts: (artifacts: string[]) => void; setArtifacts: (artifacts: string[]) => void;
selectedArtifact: string | null; selectedArtifact: string | null;
selectedArtifactKey: string | null; selectedArtifactKey: string | null;
autoSelect: boolean; autoSelect: boolean;
@@ -38,20 +39,33 @@ export interface ArtifactsContextType {
/** Open/update a streamed write without overriding a manual close of that call. */ /** Open/update a streamed write without overriding a manual close of that call. */
openStreamedArtifact: (artifact: string, previewKey: string) => boolean; openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
deselect: () => void; deselect: () => void;
open: boolean; open: boolean;
autoOpen: boolean; autoOpen: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
} /**
* 流式写文件自动开沙箱的两阶段标记:openStreamedArtifact 先收起两侧侧栏
const ArtifactsContext = createContext<ArtifactsContextType | undefined>( * (阶段一),等收起布局落定后再真正翻转 open(阶段二)。两阶段之间为
undefined, * true —— 右侧智能体信息面板据此提前收起,避免与沙箱展开同帧叠加造成卡顿。
); */
preOpen: boolean;
interface ArtifactsProviderProps { }
children: ReactNode;
} const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
undefined,
);
interface ArtifactsProviderProps {
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);
@@ -84,12 +134,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact)); setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey)); setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") { if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false); sidebar?.setOpen(false);
} }
if (!autoSelect) { if (!autoSelect) {
setAutoSelect((previous) => (previous ? false : previous)); setAutoSelect((previous) => (previous ? false : previous));
} }
}, },
[sidebar], [sidebar],
); );
@@ -104,43 +154,70 @@ 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(() => {
selectedArtifactKeyRef.current = null; selectedArtifactKeyRef.current = null;
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;
}
const value: ArtifactsContextType = { applyOpen(isOpen);
artifacts, },
setArtifacts, [applyOpen, cancelPendingStreamedOpen],
);
open,
autoOpen, const value: ArtifactsContextType = {
autoSelect, artifacts,
setArtifacts,
open,
preOpen,
autoOpen,
autoSelect,
setOpen, setOpen,
selectedArtifact, selectedArtifact,
@@ -148,23 +225,23 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
select, select,
openStreamedArtifact, openStreamedArtifact,
deselect, deselect,
}; };
return ( return (
<ArtifactsContext.Provider value={value}> <ArtifactsContext.Provider value={value}>
{children} {children}
</ArtifactsContext.Provider> </ArtifactsContext.Provider>
); );
} }
export function useArtifacts() { export function useArtifacts() {
const context = useContext(ArtifactsContext); const context = useContext(ArtifactsContext);
if (context === undefined) { if (context === undefined) {
throw new Error("useArtifacts must be used within an ArtifactsProvider"); throw new Error("useArtifacts must be used within an ArtifactsProvider");
} }
return context; return context;
} }
export function useArtifactsOptional(): ArtifactsContextType | null { export function useArtifactsOptional(): ArtifactsContextType | null {
return useContext(ArtifactsContext) ?? null; return useContext(ArtifactsContext) ?? null;
} }