fix: 节流会商流式写文件预览,避免 Maximum update depth

write_file 的 content 在每个 SSE chunk 中持续增长,原先会反复触发 React 状态更新并打断整页;现改为按身份入参去重、节流发布 stub,并收口第 3 步为 Markdown 报告工作台。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-18 23:23:46 +08:00
1 parent dcfaa199bf
commit 30799f17be
14 files changed
+644 -703

No files matched your search

@@ -155,6 +155,7 @@ export function ArtifactFileDetail({
onDownloadClick,
toolbarActions,
headerLeadingAction,
headerLeadingActionBare = false,
researchSessionId,
}: {
className?: string;
@@ -192,6 +193,11 @@ export function ArtifactFileDetail({
toolbarActions?: ReactNode;
/** Replaces the left file-type badge for an embedded workspace action. */
headerLeadingAction?: ReactNode;
/**
* 嵌入式工作区的标题动作可直接放在标题栏中,避免再被文件类型徽标的胶囊容器包一层。
* 默认保留原有徽标容器,避免影响普通聊天和产物预览。
*/
headerLeadingActionBare?: boolean;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
@@ -1205,7 +1211,13 @@ export function ArtifactFileDetail({
<Artifact className={cn(className)}>
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8">
<div
className={cn(
"inline-flex min-w-0 items-center gap-2",
!headerLeadingActionBare &&
"bg-muted/65 rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8",
)}
>
{headerLeadingAction ?? (
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
{fileTypeLabel}
@@ -52,11 +52,17 @@ interface ArtifactsProviderProps {
children: ReactNode;
}
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []);
}, []);
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
const normalized = Array.isArray(next) ? next : [];
setArtifactsState((previous) =>
previous.length === normalized.length &&
previous.every((artifact, index) => artifact === normalized[index])
? previous
: normalized,
);
}, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
@@ -65,18 +71,24 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
);
const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
const autoOpenRef = useRef(autoOpen);
const sidebar = useSidebarSafe();
const select = useCallback(
const select = useCallback(
(artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact);
setSelectedArtifactKey(artifactPreviewKey(artifact));
const previewKey = artifactPreviewKey(artifact);
selectedArtifactKeyRef.current = previewKey;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
if (!autoSelect) {
setAutoSelect(false);
}
if (!autoSelect) {
setAutoSelect((previous) => (previous ? false : previous));
}
},
[sidebar],
);
@@ -86,11 +98,13 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
if (suppressedAutoOpenKeyRef.current === previewKey) {
return false;
}
setSelectedArtifact(artifact);
setSelectedArtifactKey(previewKey);
setAutoSelect(true);
setAutoOpen(true);
setOpenState(true);
selectedArtifactKeyRef.current = previewKey;
autoOpenRef.current = true;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
setAutoSelect((previous) => (previous ? previous : true));
setAutoOpen((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? previous : true));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
@@ -99,13 +113,26 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
[sidebar],
);
const deselect = useCallback(() => {
setSelectedArtifact(null);
setSelectedArtifactKey(null);
setAutoSelect(true);
setOpenState(false);
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));
suppressedAutoOpenKeyRef.current = null;
}, []);
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,
@@ -114,16 +141,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
open,
autoOpen,
autoSelect,
setOpen: (isOpen: boolean) => {
if (!isOpen && autoOpen) {
setAutoOpen(false);
setAutoSelect(false);
}
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
}
setOpenState(isOpen);
},
setOpen,
selectedArtifact,
selectedArtifactKey,