fix(prompts): delay the disabled look during quick reloads and writes

Refocusing the window reloads the prompt list, and reloadPending fed the
buttons' disabled prop directly, so every focus flashed most of the
column grey; toggling a prompt (also on Pi) did the same. Like the MCP
and Skills panels, show the disabled state only after it lasts 300ms
via useDelayedFlag. Handlers still check interactionBlocked, and the
two Pi entry points that relied on disabled alone (toggle, new
template) now check it too. Open forms and dialogs still disable
immediately.
This commit is contained in:
Jason committed 2026-10-03 23:29:00 +08:00
1 parent c55843ffad
commit b074f83589
3 files changed
+54 -10

No files matched your search

+11 -4
View File
@@ -5,6 +5,7 @@ import { Notice } from "@/components/ui/notice";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { promptsApi, type AppId, type Prompt } from "@/lib/api";
import { usePromptActions } from "@/hooks/usePromptActions";
import { useDelayedFlag } from "@/hooks/useDelayedFlag";
import { useTauriEvent } from "@/hooks/useTauriEvent";
import PromptFormPanel from "./PromptFormPanel";
import { PromptCopyDialog } from "./PromptCopyDialog";
@@ -86,6 +87,10 @@ const PiPromptPanel: React.FC<PiPromptPanelProps> = ({
const interactionBlocked =
loading || writePending || isFormOpen || dialogOpen;
const navigationBlocked = writePending || isFormOpen || dialogOpen;
// 外观上的禁用晚 300ms 才出现:点一下启用这类很快的写入不让整列按钮闪一下变灰。
// 拦截仍看 interactionBlocked;表单、对话框打开时照常立即禁用。
const controlsDisabled =
useDelayedFlag(loading || writePending) || isFormOpen || dialogOpen;
useEffect(() => {
if (open) void reload();
@@ -156,6 +161,7 @@ const PiPromptPanel: React.FC<PiPromptPanelProps> = ({
const hasExternalPrompt = fileHasContent && activePrompt === undefined;
const handleToggle = (id: string, enabled: boolean) => {
if (interactionBlocked) return;
const before = prompts;
const target = before[id];
if (!target) return;
@@ -385,8 +391,9 @@ const PiPromptPanel: React.FC<PiPromptPanelProps> = ({
activeTab === "templates"
? t("pi.prompts.newTemplate")
: t("prompts.add"),
disabled: interactionBlocked,
disabled: controlsDisabled,
onClick: () => {
if (interactionBlocked) return;
if (activeTab === "templates") {
templatesRef.current?.openCreate();
} else {
@@ -442,7 +449,7 @@ const PiPromptPanel: React.FC<PiPromptPanelProps> = ({
<Button
variant="neutral"
size="compact"
disabled={interactionBlocked}
disabled={controlsDisabled}
onClick={handleImport}
>
{t("pi.prompts.saveToLibrary")}
@@ -469,7 +476,7 @@ const PiPromptPanel: React.FC<PiPromptPanelProps> = ({
app="pi"
displayPath={displayPath}
fileText={fileText}
disabled={interactionBlocked}
disabled={controlsDisabled}
onImport={handleImport}
onAdd={() => openGlobalPromptForm()}
/>
@@ -479,7 +486,7 @@ const PiPromptPanel: React.FC<PiPromptPanelProps> = ({
prompts={prompts}
searchQuery={searchQuery}
listLabel={t("pi.prompts.libraryLabel")}
disabled={interactionBlocked}
disabled={controlsDisabled}
onClearSearch={() => setSearchQuery("")}
onToggle={handleToggle}
onEdit={openGlobalPromptForm}
+10 -3
View File
@@ -4,6 +4,7 @@ import { toast } from "@/lib/toast";
import { APP_DISPLAY_NAME } from "@/components/shell/AppGlyph";
import { promptsApi, type AppId, type Prompt } from "@/lib/api";
import { usePromptActions } from "@/hooks/usePromptActions";
import { useDelayedFlag } from "@/hooks/useDelayedFlag";
import { useTauriEvent } from "@/hooks/useTauriEvent";
import PiPromptPanel from "./PiPromptPanel";
import PromptFormPanel from "./PromptFormPanel";
@@ -76,6 +77,12 @@ const StandardPromptPanel: React.FC<PromptPanelProps> = ({
const interactionBlocked =
loading || reloadPending || writePending || isFormOpen || dialogOpen;
const navigationBlocked = writePending || isFormOpen || dialogOpen;
// 外观上的禁用晚 300ms 才出现:切回窗口时的重读、点一下启用这类很快的操作,
// 不让整列按钮闪一下变灰。拦截仍看 interactionBlocked 和写锁;表单、对话框打开时照常立即禁用。
const controlsDisabled =
useDelayedFlag(loading || reloadPending || writePending) ||
isFormOpen ||
dialogOpen;
useEffect(() => {
onInteractionBlockedChange?.(interactionBlocked);
@@ -493,7 +500,7 @@ const StandardPromptPanel: React.FC<PromptPanelProps> = ({
primary={{
label: t("prompts.add"),
onClick: handleAdd,
disabled: interactionBlocked,
disabled: controlsDisabled,
}}
moreItems={moreItems(
{
@@ -531,7 +538,7 @@ const StandardPromptPanel: React.FC<PromptPanelProps> = ({
app={appId}
displayPath={displayPath}
fileText={fileText}
disabled={interactionBlocked}
disabled={controlsDisabled}
onImport={() => void handleImport()}
onAdd={handleAdd}
/>
@@ -541,7 +548,7 @@ const StandardPromptPanel: React.FC<PromptPanelProps> = ({
prompts={prompts}
searchQuery={searchQuery}
listLabel={t("prompts.listLabel", { app: APP_DISPLAY_NAME[appId] })}
disabled={interactionBlocked}
disabled={controlsDisabled}
onClearSearch={() => setSearchQuery("")}
onToggle={(id, enabled) => void handleToggle(id, enabled)}
onEdit={handleEdit}
+33 -3
View File
@@ -585,7 +585,8 @@ describe("PromptPanel", () => {
await waitFor(() => {
expect(onInteractionBlockedChange).toHaveBeenLastCalledWith(true);
});
expect(toggle).toBeDisabled();
// 外观上的禁用晚 300ms 出现(useDelayedFlag),拦截本身是立即的
await waitFor(() => expect(toggle).toBeDisabled());
expect(editButton("Harbor Prompt")).toBeDisabled();
expect(screen.getByRole("button", { name: "prompts.add" })).toBeDisabled();
@@ -609,7 +610,9 @@ describe("PromptPanel", () => {
});
expect(screen.getByText("prompts.loading")).toBeInTheDocument();
const add = screen.getByRole("button", { name: "prompts.add" });
expect(add).toBeDisabled();
fireEvent.click(add);
expect(screen.queryByTestId("prompt-form")).not.toBeInTheDocument();
await waitFor(() => expect(add).toBeDisabled());
fireEvent.click(add);
expect(screen.queryByTestId("prompt-form")).not.toBeInTheDocument();
expect(mocks.toggleEnabled).not.toHaveBeenCalled();
@@ -710,6 +713,29 @@ describe("PromptPanel", () => {
expect(mocks.reload).not.toHaveBeenCalled();
});
it("keeps controls enabled through a quick focus reload but still blocks clicks", async () => {
renderPanel();
await waitForPanelReady();
let resolveReload!: () => void;
mocks.reload.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveReload = resolve;
}),
);
fireEvent(window, new Event("focus"));
const add = screen.getByRole("button", { name: "prompts.add" });
expect(add).not.toBeDisabled();
fireEvent.click(add);
expect(screen.queryByTestId("prompt-form")).not.toBeInTheDocument();
await act(async () => {
resolveReload();
await Promise.resolve();
});
expect(add).not.toBeDisabled();
});
it.each(["focus", "prompt-imported"])(
"queues %s reloads while the drawer is open",
async (trigger) => {
@@ -746,7 +772,11 @@ describe("PromptPanel", () => {
const view = renderPanel();
await waitFor(() => expect(claudeReload).toHaveBeenCalledTimes(1));
expect(screen.getByRole("button", { name: "prompts.add" })).toBeDisabled();
await waitFor(() =>
expect(
screen.getByRole("button", { name: "prompts.add" }),
).toBeDisabled(),
);
view.rerenderWith({ appId: "codex" });
await waitFor(() => expect(codexReload).toHaveBeenCalledTimes(1));