fix(ui): panel flicker (#8201)

* fix(ui): prevent full-page panel flicker

* fix(workspace): cancel pending note reads before deletion
This commit is contained in:
NestorRay authored and GitHub committed 2026-10-13 10:36:31 +08:00
1 parent 90fb37ffb7
commit d9a62313c8
8 files changed
+351 -47

No files matched your search

+2 -2
View File
@@ -22,7 +22,7 @@ interface FullScreenPanelProps {
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
/** Entry/exit motion. Nested navigation panels can opt into a horizontal transition. */
/** Defaults to no motion to avoid revealing the underlying page during a fade. */
motionPreset?: "none" | "fade" | "slide-from-right";
/**
* 覆盖内容区滚动容器的内边距/间距类。默认 `px-6 py-6 space-y-6`。
@@ -73,7 +73,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
children,
footer,
contentClassName,
motionPreset = "fade",
motionPreset = "none",
trackUnsavedChanges = false,
}) => {
// 面板关掉后组件可能还挂着:只在打开时登记,关掉即撤销
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useLayoutEffect, useCallback, useMemo } from "react";
import { useDarkMode } from "@/hooks/useDarkMode";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff, RefreshCw } from "lucide-react";
@@ -62,8 +62,8 @@ export function UniversalProviderFormModal({
const [pendingProvider, setPendingProvider] =
useState<UniversalProvider | null>(null);
// 初始化表单
useEffect(() => {
// Populate before paint so opening the panel never shows empty/stale fields.
useLayoutEffect(() => {
if (editingProvider) {
// 编辑模式:加载现有数据
setName(editingProvider.name);
+25 -21
View File
@@ -53,7 +53,7 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
// Edit state
const [editingFile, setEditingFile] = useState<string | null>(null);
const [content, setContent] = useState("");
const [loadingContent, setLoadingContent] = useState(false);
const contentRequestRef = useRef(0);
const [saving, setSaving] = useState(false);
// Delete state
@@ -199,19 +199,25 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
}, [isOpen, loadFiles]);
// Open file for editing
useEffect(() => {
// Ignore reads completed after closing the panel or unmounting it.
return () => {
contentRequestRef.current += 1;
};
}, [isOpen]);
const openFile = useCallback(
async (filename: string) => {
setLoadingContent(true);
setEditingFile(filename);
const request = ++contentRequestRef.current;
try {
const data = await workspaceApi.readDailyMemoryFile(filename);
if (request !== contentRequestRef.current) return;
setContent(data ?? "");
setEditingFile(filename);
} catch (err) {
if (request !== contentRequestRef.current) return;
console.error("Failed to read daily memory file:", err);
toast.error(t("workspace.dailyMemory.loadFailed"));
setEditingFile(null);
} finally {
setLoadingContent(false);
}
},
[t],
@@ -228,6 +234,7 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
return;
}
// Open editor with empty content — no file created until user saves
contentRequestRef.current += 1;
setEditingFile(filename);
setContent("");
}, [files, openFile]);
@@ -250,6 +257,8 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
// Delete file
const handleDelete = useCallback(async () => {
if (!deletingFile) return;
// A pending read must not open the file during or after deletion.
contentRequestRef.current += 1;
try {
await workspaceApi.deleteDailyMemoryFile(deletingFile);
toast.success(t("workspace.dailyMemory.deleteSuccess"));
@@ -291,6 +300,7 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
// Close panel entirely — clear search state
const handleClose = useCallback(() => {
contentRequestRef.current += 1;
setEditingFile(null);
setContent("");
setIsSearchOpen(false);
@@ -309,24 +319,18 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
title={t("workspace.editing", { filename: editingFile })}
onClose={handleBackToList}
footer={
<Button onClick={handleSave} disabled={saving || loadingContent}>
<Button onClick={handleSave} disabled={saving}>
{saving ? t("common.saving") : t("common.save")}
</Button>
}
>
{loadingContent ? (
<div className="flex items-center justify-center h-64 text-fg-2">
{t("prompts.loading")}
</div>
) : (
<MarkdownEditor
value={content}
onChange={setContent}
darkMode={isDarkMode}
placeholder={`# ${editingFile}\n\n...`}
minHeight="calc(100vh - 240px)"
/>
)}
<MarkdownEditor
value={content}
onChange={setContent}
darkMode={isDarkMode}
placeholder={`# ${editingFile}\n\n...`}
minHeight="calc(100vh - 240px)"
/>
</FullScreenPanel>
<ConfirmDialog
@@ -490,7 +494,7 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
</div>
)
) : // --- Normal file list ---
loadingList ? (
loadingList && files.length === 0 ? (
<div className="flex items-center justify-center h-48 text-fg-2">
{t("prompts.loading")}
</div>
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "@/lib/toast";
import { Button } from "@/components/ui/button";
@@ -19,7 +19,9 @@ const WorkspaceFileEditor: React.FC<WorkspaceFileEditorProps> = ({
}) => {
const { t } = useTranslation();
const [content, setContent] = useState("");
const [loading, setLoading] = useState(true);
const [loadedFile, setLoadedFile] = useState<string | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const [saving, setSaving] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
@@ -36,19 +38,26 @@ const WorkspaceFileEditor: React.FC<WorkspaceFileEditorProps> = ({
}, []);
useEffect(() => {
setLoadedFile(null);
if (!isOpen || !filename) return;
setLoading(true);
let cancelled = false;
workspaceApi
.readFile(filename)
.then((data) => {
if (cancelled) return;
setContent(data ?? "");
setLoadedFile(filename);
})
.catch((err) => {
if (cancelled) return;
console.error("Failed to read workspace file:", err);
toast.error(t("workspace.loadFailed"));
})
.finally(() => setLoading(false));
onCloseRef.current();
});
return () => {
cancelled = true;
};
}, [isOpen, filename, t]);
const handleSave = useCallback(async () => {
@@ -66,28 +75,23 @@ const WorkspaceFileEditor: React.FC<WorkspaceFileEditorProps> = ({
return (
<FullScreenPanel
isOpen={isOpen}
// Keep the file list visible until the editor content is ready.
isOpen={isOpen && loadedFile === filename}
title={t("workspace.editing", { filename })}
onClose={onClose}
footer={
<Button onClick={handleSave} disabled={saving || loading}>
<Button onClick={handleSave} disabled={saving}>
{saving ? t("common.saving") : t("common.save")}
</Button>
}
>
{loading ? (
<div className="flex items-center justify-center h-64 text-fg-2">
{t("prompts.loading")}
</div>
) : (
<MarkdownEditor
value={content}
onChange={setContent}
darkMode={isDarkMode}
placeholder={`# ${filename}\n\n...`}
minHeight="calc(100vh - 240px)"
/>
)}
<MarkdownEditor
value={content}
onChange={setContent}
darkMode={isDarkMode}
placeholder={`# ${filename}\n\n...`}
minHeight="calc(100vh - 240px)"
/>
</FullScreenPanel>
);
};
+128
View File
@@ -0,0 +1,128 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import DailyMemoryPanel from "@/components/workspace/DailyMemoryPanel";
import { workspaceApi } from "@/lib/api/workspace";
vi.mock("@/lib/api/workspace", () => ({
workspaceApi: {
listDailyMemoryFiles: vi.fn(),
readDailyMemoryFile: vi.fn(),
deleteDailyMemoryFile: vi.fn(),
},
}));
vi.mock("@/components/MarkdownEditor", () => ({
default: ({ value }: { value: string }) => (
<textarea aria-label="Content" readOnly value={value} />
),
}));
function deferred() {
let resolve!: (value: string) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<string>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
beforeEach(() => {
vi.mocked(workspaceApi.listDailyMemoryFiles).mockResolvedValue([
{
filename: "2026-10-10.md",
date: "2026-10-10",
sizeBytes: 10,
modifiedAt: 0,
preview: "First note",
},
{
filename: "2026-10-11.md",
date: "2026-10-11",
sizeBytes: 10,
modifiedAt: 0,
preview: "Second note",
},
]);
});
describe("DailyMemoryPanel opening", () => {
it.each(["before", "after"])(
"ignores a pending read that resolves %s deletion completes",
async (readTiming) => {
const read = deferred();
const deletion = deferred();
vi.mocked(workspaceApi.readDailyMemoryFile).mockReturnValueOnce(
read.promise,
);
vi.mocked(workspaceApi.deleteDailyMemoryFile).mockReturnValueOnce(
deletion.promise.then(() => undefined),
);
render(<DailyMemoryPanel isOpen onClose={vi.fn()} />);
const row = await screen.findByRole("button", { name: /First note/ });
fireEvent.click(row);
// The row's trailing delete control stops propagation to openFile.
fireEvent.click(row.lastElementChild!);
fireEvent.click(screen.getByRole("button", { name: "common.confirm" }));
expect(workspaceApi.deleteDailyMemoryFile).toHaveBeenCalledWith(
"2026-10-10.md",
);
if (readTiming === "before") {
await act(async () => read.resolve("Deleted content"));
expect(screen.queryByRole("textbox")).toBeNull();
}
vi.mocked(workspaceApi.listDailyMemoryFiles).mockResolvedValue([]);
await act(async () => deletion.resolve(""));
if (readTiming === "after") {
await act(async () => read.resolve("Deleted content"));
}
expect(screen.queryByRole("textbox")).toBeNull();
expect(screen.queryByRole("button", { name: "common.save" })).toBeNull();
expect(screen.queryByText("First note")).toBeNull();
},
);
it("keeps the list visible until the latest selected note is ready", async () => {
const first = deferred();
const second = deferred();
vi.mocked(workspaceApi.readDailyMemoryFile)
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
render(<DailyMemoryPanel isOpen onClose={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: /First note/ }));
expect(screen.getByText("Second note")).toBeVisible();
expect(screen.queryByRole("textbox")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /Second note/ }));
await act(async () => second.resolve("Second content"));
expect(screen.getByRole("textbox")).toHaveValue("Second content");
await act(async () => first.resolve("First content"));
expect(screen.getByRole("textbox")).toHaveValue("Second content");
});
it("ignores a read completed after closing and reopening", async () => {
const read = deferred();
vi.mocked(workspaceApi.readDailyMemoryFile).mockReturnValueOnce(
read.promise,
);
const onClose = vi.fn();
const view = render(<DailyMemoryPanel isOpen onClose={onClose} />);
fireEvent.click(await screen.findByRole("button", { name: /First note/ }));
view.rerender(<DailyMemoryPanel isOpen={false} onClose={onClose} />);
await act(async () => read.resolve("Late content"));
view.rerender(<DailyMemoryPanel isOpen onClose={onClose} />);
expect(await screen.findByText("First note")).toBeVisible();
expect(screen.queryByRole("textbox")).toBeNull();
});
it("keeps the list visible when reading fails", async () => {
const read = deferred();
vi.mocked(workspaceApi.readDailyMemoryFile).mockReturnValueOnce(
read.promise,
);
render(<DailyMemoryPanel isOpen onClose={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: /First note/ }));
await act(async () => read.reject(new Error("read failed")));
expect(screen.getByText("First note")).toBeVisible();
expect(screen.queryByRole("textbox")).toBeNull();
});
});
+43 -1
View File
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { DRAG_REGION_ENABLED } from "@/lib/platform";
@@ -14,6 +14,48 @@ const Panels = ({ innerOpen }: { innerOpen: boolean }) => (
</>
);
describe("FullScreenPanel motion", () => {
it("is opaque from the first render, including after reopening", async () => {
const panel = (isOpen: boolean) => (
<FullScreenPanel isOpen={isOpen} title="Editor" onClose={() => undefined}>
editor content
</FullScreenPanel>
);
const view = render(panel(true));
const surface = () => screen.getByText("editor content").closest(".bg-app");
expect(surface()).toHaveStyle({ opacity: "1" });
view.rerender(panel(false));
// If AnimatePresence retains the closing page, it must remain opaque.
const closing = screen.queryByText("editor content")?.closest(".bg-app");
if (closing) expect(closing).toHaveStyle({ opacity: "1" });
await waitFor(() =>
expect(screen.queryByText("editor content")).toBeNull(),
);
view.rerender(panel(true));
expect(surface()).toHaveStyle({ opacity: "1" });
});
it("keeps the explicit horizontal navigation transition", () => {
render(
<FullScreenPanel
isOpen
title="Authorization"
onClose={() => undefined}
motionPreset="slide-from-right"
>
authorization content
</FullScreenPanel>,
);
const surface = screen
.getByText("authorization content")
.closest(".bg-app");
expect(surface).toHaveStyle({ transform: "translateX(100%)" });
expect(surface).not.toHaveStyle({ opacity: "0" });
});
});
describe("FullScreenPanel body scroll locking", () => {
afterEach(() => {
document.body.style.overflow = "";
@@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import UsageScriptModal from "@/components/UsageScriptModal";
import { PricingEditModal } from "@/components/usage/PricingEditModal";
vi.mock("@/lib/query", () => ({
useSettingsQuery: () => ({ data: { usageConfirmed: true } }),
}));
vi.mock("@/lib/query/usage", () => ({
useUpdateModelPricing: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock("@/components/JsonEditor", () => ({ default: () => <div /> }));
describe("Usage pages render without a transparent opening frame", () => {
it("opens usage query configuration fully opaque", () => {
render(
<QueryClientProvider client={new QueryClient()}>
<UsageScriptModal
isOpen
provider={{ id: "test", name: "Test Provider", settingsConfig: {} }}
appId="claude"
onClose={vi.fn()}
onSave={vi.fn()}
/>
</QueryClientProvider>,
);
expect(screen.getByRole("heading").closest(".bg-app")).toHaveStyle({
opacity: "1",
});
});
it.each([true, false])("opens pricing fully opaque (isNew=%s)", (isNew) => {
render(
<PricingEditModal
open
isNew={isNew}
model={{
modelId: "test",
displayName: "Test",
inputCostPerMillion: "1",
outputCostPerMillion: "2",
cacheReadCostPerMillion: "0",
cacheCreationCostPerMillion: "0",
}}
onClose={vi.fn()}
/>,
);
expect(screen.getByRole("heading").closest(".bg-app")).toHaveStyle({
opacity: "1",
});
});
});
@@ -0,0 +1,73 @@
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import WorkspaceFileEditor from "@/components/workspace/WorkspaceFileEditor";
import { workspaceApi } from "@/lib/api/workspace";
vi.mock("@/lib/api/workspace", () => ({
workspaceApi: { readFile: vi.fn(), writeFile: vi.fn() },
}));
vi.mock("@/components/MarkdownEditor", () => ({
default: ({ value }: { value: string }) => (
<textarea aria-label="Content" readOnly value={value} />
),
}));
function deferred() {
let resolve!: (value: string) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<string>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("WorkspaceFileEditor opening", () => {
it("waits for content and ignores an older file read", async () => {
const first = deferred();
const second = deferred();
vi.mocked(workspaceApi.readFile)
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const onClose = vi.fn();
const view = render(
<WorkspaceFileEditor isOpen filename="A.md" onClose={onClose} />,
);
expect(screen.queryByRole("heading")).toBeNull();
expect(screen.queryByText("prompts.loading")).toBeNull();
view.rerender(
<WorkspaceFileEditor isOpen filename="B.md" onClose={onClose} />,
);
await act(async () => second.resolve("B content"));
expect(screen.getByRole("textbox")).toHaveValue("B content");
expect(screen.getByRole("textbox").closest(".bg-app")).toHaveStyle({
opacity: "1",
});
await act(async () => first.resolve("A content"));
expect(screen.getByRole("textbox")).toHaveValue("B content");
});
it("does not reopen after closing during a read", async () => {
const read = deferred();
vi.mocked(workspaceApi.readFile).mockReturnValueOnce(read.promise);
const onClose = vi.fn();
const view = render(
<WorkspaceFileEditor isOpen filename="A.md" onClose={onClose} />,
);
view.rerender(
<WorkspaceFileEditor isOpen={false} filename="A.md" onClose={onClose} />,
);
await act(async () => read.resolve("late content"));
expect(screen.queryByRole("textbox")).toBeNull();
});
it("returns to the list on failure instead of opening an empty editor", async () => {
const read = deferred();
vi.mocked(workspaceApi.readFile).mockReturnValueOnce(read.promise);
const onClose = vi.fn();
render(<WorkspaceFileEditor isOpen filename="A.md" onClose={onClose} />);
await act(async () => read.reject(new Error("read failed")));
expect(onClose).toHaveBeenCalledOnce();
expect(screen.queryByRole("textbox")).toBeNull();
});
});