From 6e9e89a6cb7f9d6ab3fa3f2a7718da347fd239cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Wed, 26 Aug 2026 18:24:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B5=81=E5=BC=8F=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=BC=80=E6=B2=99=E7=AE=B1=E5=88=86=E9=98=B6=E6=AE=B5=E6=94=B6?= =?UTF-8?q?=E8=B5=B7=E4=BE=A7=E6=A0=8F=EF=BC=8C=E9=81=BF=E5=85=8D=E5=90=8C?= =?UTF-8?q?=E5=B8=A7=E5=B8=83=E5=B1=80=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 preOpen 两阶段标记,先错开收起左右侧栏再展开沙箱;智能体详情面板据此提前收起,减少与沙箱展开同帧叠加造成的卡顿。 Co-authored-by: Cursor --- .../workspace/agents/agent-detail-panel.tsx | 5 +- .../workspace/artifacts/context.tsx | 223 ++++++++++++------ 2 files changed, 154 insertions(+), 74 deletions(-) diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index ef2178b6..69cfc6a4 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -267,8 +267,11 @@ export function AgentDetailPanel({ const artifactsCtx = useArtifactsOptional(); const artifactCount = artifactsCtx?.artifacts.length ?? 0; const selectedArtifact = artifactsCtx?.selectedArtifact ?? null; + // preOpen:流式自动开沙箱的两阶段标记 —— 沙箱真正展开(open 翻转)前就 + // 先收起本面板,让收起布局先落地,避免与沙箱展开同帧叠加造成卡顿。 const artifactPanelVisible = - !!artifactsCtx?.open && (!!selectedArtifact || artifactCount > 0); + (!!artifactsCtx?.open || !!artifactsCtx?.preOpen) && + (!!selectedArtifact || artifactCount > 0); const prevArtifactPanelVisible = useRef(false); useEffect(() => { const opened = artifactPanelVisible && !prevArtifactPanelVisible.current; diff --git a/frontend-web/src/components/workspace/artifacts/context.tsx b/frontend-web/src/components/workspace/artifacts/context.tsx index 75163228..ab23b202 100644 --- a/frontend-web/src/components/workspace/artifacts/context.tsx +++ b/frontend-web/src/components/workspace/artifacts/context.tsx @@ -1,13 +1,14 @@ -import { - createContext, +import { + createContext, useCallback, useContext, + useEffect, useRef, useState, - type ReactNode, -} from "react"; - -import { useSidebarSafe } from "@/components/ui/sidebar"; + type ReactNode, +} from "react"; + +import { useSidebarSafe } from "@/components/ui/sidebar"; import { env } from "@/env"; export function artifactPreviewKey(artifact: string): string { @@ -26,11 +27,11 @@ export function artifactPreviewKey(artifact: string): string { } return artifact; } - -export interface ArtifactsContextType { - artifacts: string[]; - setArtifacts: (artifacts: string[]) => void; - + +export interface ArtifactsContextType { + artifacts: string[]; + setArtifacts: (artifacts: string[]) => void; + selectedArtifact: string | null; selectedArtifactKey: string | null; autoSelect: boolean; @@ -38,20 +39,33 @@ export interface ArtifactsContextType { /** Open/update a streamed write without overriding a manual close of that call. */ openStreamedArtifact: (artifact: string, previewKey: string) => boolean; deselect: () => void; - - open: boolean; - autoOpen: boolean; - setOpen: (open: boolean) => void; -} - -const ArtifactsContext = createContext( - undefined, -); - -interface ArtifactsProviderProps { - children: ReactNode; -} - + + open: boolean; + autoOpen: boolean; + setOpen: (open: boolean) => void; + /** + * 流式写文件自动开沙箱的两阶段标记:openStreamedArtifact 先收起两侧侧栏 + * (阶段一),等收起布局落定后再真正翻转 open(阶段二)。两阶段之间为 + * true —— 右侧智能体信息面板据此提前收起,避免与沙箱展开同帧叠加造成卡顿。 + */ + preOpen: boolean; +} + +const ArtifactsContext = createContext( + 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) { const [artifacts, setArtifactsState] = useState([]); const setArtifacts = useCallback((next: string[]) => { @@ -69,14 +83,50 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { const [open, setOpenState] = useState( env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", ); + const [preOpen, setPreOpen] = useState(false); const [autoOpen, setAutoOpen] = useState(true); const suppressedAutoOpenKeyRef = useRef(null); // 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次 // Context 状态变化都重建 setter 并触发依赖它的布局 effect。 const selectedArtifactKeyRef = useRef(selectedArtifactKey); const autoOpenRef = useRef(autoOpen); + const openStateRef = useRef(open); + const preOpenTimerRef = useRef(null); + const openTimerRef = useRef(null); 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( (artifact: string, autoSelect = false) => { const previewKey = artifactPreviewKey(artifact); @@ -84,12 +134,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { setSelectedArtifact((previous) => (previous === artifact ? previous : artifact)); setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey)); if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") { - sidebar?.setOpen(false); - } + sidebar?.setOpen(false); + } if (!autoSelect) { setAutoSelect((previous) => (previous ? false : previous)); } - }, + }, [sidebar], ); @@ -104,43 +154,70 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey)); setAutoSelect((previous) => (previous ? previous : true)); setAutoOpen((previous) => (previous ? previous : true)); - setOpenState((previous) => (previous ? previous : true)); + // 阶段一(t=0):立即收起左侧聊天记录侧栏。 if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") { 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; }, - [sidebar], + [applyOpen, cancelPendingStreamedOpen, sidebar], ); - + const deselect = useCallback(() => { selectedArtifactKeyRef.current = null; setSelectedArtifact((previous) => (previous === null ? previous : null)); setSelectedArtifactKey((previous) => (previous === null ? previous : null)); setAutoSelect((previous) => (previous ? previous : true)); - setOpenState((previous) => (previous ? false : previous)); + cancelPendingStreamedOpen(); + applyOpen(false); suppressedAutoOpenKeyRef.current = null; - }, []); + }, [applyOpen, cancelPendingStreamedOpen]); - const setOpen = useCallback((isOpen: boolean) => { - if (!isOpen && autoOpenRef.current) { - autoOpenRef.current = false; - setAutoOpen((previous) => (previous ? false : previous)); - setAutoSelect((previous) => (previous ? false : previous)); - } - if (!isOpen) { - suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current; - } - setOpenState((previous) => (previous === isOpen ? previous : isOpen)); - }, []); - - const value: ArtifactsContextType = { - artifacts, - setArtifacts, - - open, - autoOpen, - autoSelect, + const setOpen = useCallback( + (isOpen: boolean) => { + if (!isOpen) { + // 两阶段等待期间用户手动关闭:取消尚未触发的自动展开。 + cancelPendingStreamedOpen(); + if (autoOpenRef.current) { + autoOpenRef.current = false; + setAutoOpen((previous) => (previous ? false : previous)); + setAutoSelect((previous) => (previous ? false : previous)); + } + suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current; + } + applyOpen(isOpen); + }, + [applyOpen, cancelPendingStreamedOpen], + ); + + const value: ArtifactsContextType = { + artifacts, + setArtifacts, + + open, + preOpen, + autoOpen, + autoSelect, setOpen, selectedArtifact, @@ -148,23 +225,23 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { select, openStreamedArtifact, deselect, - }; - - return ( - - {children} - - ); -} - -export function useArtifacts() { - const context = useContext(ArtifactsContext); - if (context === undefined) { - throw new Error("useArtifacts must be used within an ArtifactsProvider"); - } - return context; -} - -export function useArtifactsOptional(): ArtifactsContextType | null { - return useContext(ArtifactsContext) ?? null; -} + }; + + return ( + + {children} + + ); +} + +export function useArtifacts() { + const context = useContext(ArtifactsContext); + if (context === undefined) { + throw new Error("useArtifacts must be used within an ArtifactsProvider"); + } + return context; +} + +export function useArtifactsOptional(): ArtifactsContextType | null { + return useContext(ArtifactsContext) ?? null; +}