fix(webui): prevent redundant thread and media reloads (#5164)

This commit is contained in:
chengyongru
2026-07-30 10:25:22 +08:00
committed by GitHub
parent fc73d5ff39
commit 11fcd9cc5f
29 changed files with 1465 additions and 247 deletions
+295 -42
View File
@@ -180,11 +180,16 @@ function makeClient() {
};
}
function wrap(client: ReturnType<typeof makeClient>, children: ReactNode, modelName?: string | null) {
function wrap(
client: ReturnType<typeof makeClient>,
children: ReactNode,
modelName?: string | null,
token = "tok",
) {
return (
<ClientProvider
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
token="tok"
token={token}
modelName={modelName ?? null}
>
{children}
@@ -241,6 +246,26 @@ function httpJson(body: unknown) {
};
}
function setDocumentVisibility(value: DocumentVisibilityState): void {
Object.defineProperty(document, "visibilityState", {
configurable: true,
value,
});
document.dispatchEvent(new Event("visibilitychange"));
}
function restoreDocumentVisibility(
descriptor: PropertyDescriptor | undefined,
): void {
if (descriptor) {
Object.defineProperty(document, "visibilityState", descriptor);
} else {
delete (document as Document & {
visibilityState?: DocumentVisibilityState;
}).visibilityState;
}
}
interface ThreadResizeObserverInstance {
elements: Element[];
callback: ResizeObserverCallback;
@@ -1758,7 +1783,7 @@ describe("ThreadShell", () => {
expect(screen.getByText("row from the expired latest window")).toBeInTheDocument(),
);
act(() => document.dispatchEvent(new Event("visibilitychange")));
act(() => client._emitSessionUpdate("window-reset-chat", "thread"));
await waitFor(() =>
expect(screen.getByText("answer in the new latest window")).toBeInTheDocument(),
@@ -1776,7 +1801,7 @@ describe("ThreadShell", () => {
);
});
it("recovers an uncommitted reset lineage on the next foreground hydrate", async () => {
it("recovers an uncommitted reset lineage on the next canonical hydrate", async () => {
const client = makeClient();
let chatACalls = 0;
vi.stubGlobal(
@@ -1826,7 +1851,7 @@ describe("ThreadShell", () => {
expect(screen.getByText("committed old lineage")).toBeInTheDocument();
expect(screen.queryByText("disjoint new lineage")).not.toBeInTheDocument();
act(() => document.dispatchEvent(new Event("visibilitychange")));
act(() => client._emitSessionUpdate("lineage-chat-a", "thread"));
await waitFor(() => expect(chatACalls).toBe(3));
await waitFor(() => expect(screen.getByText("disjoint new lineage")).toBeInTheDocument());
@@ -1874,7 +1899,7 @@ describe("ThreadShell", () => {
);
await waitFor(() => expect(screen.getByText("old canonical row")).toBeInTheDocument());
act(() => document.dispatchEvent(new Event("visibilitychange")));
act(() => client._emitSessionUpdate("reset-tail-race", "thread"));
await waitFor(() => expect(historyCalls).toBe(2));
act(() => {
@@ -1938,7 +1963,7 @@ describe("ThreadShell", () => {
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(screen.getByText("rejected local turn")).toBeInTheDocument());
act(() => document.dispatchEvent(new Event("visibilitychange")));
act(() => client._emitSessionUpdate("empty-reset-chat", "thread"));
await waitFor(() => expect(historyCalls).toBe(2));
await waitFor(() =>
@@ -2023,7 +2048,7 @@ describe("ThreadShell", () => {
});
canonicalComplete = true;
act(() => document.dispatchEvent(new Event("visibilitychange")));
act(() => client._emitSessionUpdate("strict-canonical", "thread"));
await waitFor(() => expect(screen.getByText("strict canonical answer")).toBeInTheDocument());
expect(client.reconcileCanonicalCompletion).toHaveBeenCalledTimes(1);
@@ -2094,7 +2119,7 @@ describe("ThreadShell", () => {
.mockImplementationOnce(() => false)
.mockImplementation((...args) => reconcileAfterReject?.(...args) ?? false);
act(() => document.dispatchEvent(new Event("visibilitychange")));
act(() => client._emitSessionUpdate("layout-recheck", "thread"));
await waitFor(() => expect(historyCalls).toBe(2));
await waitFor(() =>
@@ -2104,7 +2129,7 @@ describe("ThreadShell", () => {
expect(screen.queryByText("layout canonical answer")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
act(() => document.dispatchEvent(new Event("visibilitychange")));
act(() => client._emitSessionUpdate("layout-recheck", "thread"));
await waitFor(() => expect(historyCalls).toBe(3));
await waitFor(() => expect(screen.getByText("layout canonical answer")).toBeInTheDocument());
@@ -2258,7 +2283,7 @@ describe("ThreadShell", () => {
);
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
act(() => document.dispatchEvent(new Event("visibilitychange")));
act(() => client._emitSessionUpdate("run-generation-chat", "thread"));
await waitFor(() => expect(historyCalls).toBe(2));
const newTurnId = "turn-started-during-refresh";
@@ -2351,7 +2376,7 @@ describe("ThreadShell", () => {
});
await waitFor(() => expect(screen.getByText("partial")).toBeInTheDocument());
act(() => document.dispatchEvent(new Event("visibilitychange")));
act(() => client._emitSessionUpdate("late-frame-chat", "thread"));
await waitFor(() =>
expect(screen.getByText("canonical complete answer")).toBeInTheDocument(),
);
@@ -2435,7 +2460,7 @@ describe("ThreadShell", () => {
});
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
act(() => document.dispatchEvent(new Event("visibilitychange")));
act(() => client._emitSessionUpdate("visibility-complete-a", "thread"));
await waitFor(() => expect(screen.getByText("completed while hidden")).toBeInTheDocument());
expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument();
expect(client.getRunStartedAt("visibility-complete-a")).toBeNull();
@@ -2495,7 +2520,7 @@ describe("ThreadShell", () => {
});
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
act(() => document.dispatchEvent(new Event("visibilitychange")));
act(() => client._emitSessionUpdate("empty-answer", "thread"));
await waitFor(() => expect(historyCalls).toBe(2));
await waitFor(() => expect(client.reconcileCanonicalCompletion).toHaveBeenCalledWith(
@@ -2717,6 +2742,7 @@ describe("ThreadShell", () => {
it("refreshes the current thread when the page returns to the foreground", async () => {
const client = makeClient();
let historyCalls = 0;
const turnId = "turn-visible-chat";
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
vi.stubGlobal(
"fetch",
@@ -2724,16 +2750,22 @@ describe("ThreadShell", () => {
const url = String(input);
if (url.includes("websocket%3Avisible-chat/webui-thread")) {
historyCalls += 1;
return httpJson(
transcriptFromSimpleMessages(
return httpJson({
...transcriptFromSimpleMessages(
historyCalls === 1
? [{ role: "user", content: "question" }]
? [{ role: "user", content: "question", turnId }]
: [
{ role: "user", content: "question" },
{ role: "assistant", content: "answer completed in background" },
{ role: "user", content: "question", turnId },
{
role: "assistant",
content: "answer completed in background",
turnId,
},
],
),
);
has_pending_tool_calls: historyCalls === 1,
completed_turn_ids: historyCalls === 1 ? [] : [turnId],
});
}
return {
ok: false,
@@ -2757,22 +2789,23 @@ describe("ThreadShell", () => {
);
await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument());
expect(historyCalls).toBe(1);
act(() => {
client._emitChat("visible-chat", {
event: "goal_status",
chat_id: "visible-chat",
status: "running",
started_at: 6_000,
turn_id: turnId,
});
});
act(() => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
setDocumentVisibility("hidden");
});
expect(historyCalls).toBe(1);
await act(async () => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "visible",
});
document.dispatchEvent(new Event("visibilitychange"));
setDocumentVisibility("visible");
await Promise.resolve();
});
@@ -2781,11 +2814,114 @@ describe("ThreadShell", () => {
expect(screen.getByText("answer completed in background")).toBeInTheDocument(),
);
} finally {
if (visibilityDescriptor) {
Object.defineProperty(document, "visibilityState", visibilityDescriptor);
} else {
delete (document as Document & { visibilityState?: DocumentVisibilityState }).visibilityState;
}
restoreDocumentVisibility(visibilityDescriptor);
}
});
it("does not refresh an idle thread for visibility notifications", async () => {
const client = makeClient();
let historyCalls = 0;
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input).includes("websocket%3Aidle-visible-chat/webui-thread")) {
historyCalls += 1;
return httpJson(transcriptFromSimpleMessages([
{ role: "assistant", content: "settled answer" },
]));
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
try {
render(
wrap(
client,
<ThreadShell
session={session("idle-visible-chat")}
title="Idle visible chat"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(screen.getByText("settled answer")).toBeInTheDocument());
act(() => document.dispatchEvent(new Event("visibilitychange")));
act(() => {
setDocumentVisibility("hidden");
});
await act(async () => {
setDocumentVisibility("visible");
await Promise.resolve();
});
expect(historyCalls).toBe(1);
} finally {
restoreDocumentVisibility(visibilityDescriptor);
}
});
it("retries a failed hydration when the page returns to the foreground", async () => {
const client = makeClient();
let historyCalls = 0;
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input).includes("websocket%3Aretry-visible-chat/webui-thread")) {
historyCalls += 1;
if (historyCalls === 1) {
return {
ok: false,
status: 500,
json: async () => ({}),
};
}
return httpJson(transcriptFromSimpleMessages([
{ role: "assistant", content: "recovered answer" },
]));
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
try {
render(
wrap(
client,
<ThreadShell
session={session("retry-visible-chat")}
title="Retry visible chat"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(historyCalls).toBe(1));
act(() => {
setDocumentVisibility("hidden");
});
await act(async () => {
setDocumentVisibility("visible");
await Promise.resolve();
});
await waitFor(() => expect(historyCalls).toBe(2));
expect(await screen.findByText("recovered answer")).toBeInTheDocument();
} finally {
restoreDocumentVisibility(visibilityDescriptor);
}
});
@@ -2847,7 +2983,7 @@ describe("ThreadShell", () => {
expect(historyCalls).toBe(1);
});
it("does not refetch thread history for metadata-only session updates", async () => {
it("keeps rendered media mounted for metadata-only session updates", async () => {
const client = makeClient();
let historyCalls = 0;
vi.stubGlobal(
@@ -2856,12 +2992,16 @@ describe("ThreadShell", () => {
const url = String(input);
if (url.includes("websocket%3Achat-a/webui-thread")) {
historyCalls += 1;
return httpJson(
transcriptFromSimpleMessages([
{ role: "user", content: "question" },
{ role: "assistant", content: "answer" },
]),
);
const thread = transcriptFromSimpleMessages([
{ role: "user", content: "question" },
{ role: "assistant", content: "answer" },
]);
thread.messages[1]!.media = [{
kind: "image",
url: "/api/media/stable/image",
name: "answer.png",
}];
return httpJson(thread);
}
return {
ok: false,
@@ -2884,6 +3024,7 @@ describe("ThreadShell", () => {
);
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
const image = screen.getByRole("img", { name: "answer.png" });
expect(historyCalls).toBe(1);
await act(async () => {
@@ -2891,6 +3032,56 @@ describe("ThreadShell", () => {
});
expect(historyCalls).toBe(1);
expect(screen.getByRole("img", { name: "answer.png" })).toBe(image);
});
it("keeps rendered media mounted when the auth token rotates", async () => {
const client = makeClient();
let historyCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input).includes("websocket%3Atoken-media/webui-thread")) {
historyCalls += 1;
const thread = transcriptFromSimpleMessages([
{ role: "user", content: "question" },
{ role: "assistant", content: "answer" },
]);
thread.messages[1]!.media = [{
kind: "image",
url: "/api/media/stable/token-image",
name: "token-answer.png",
}];
return httpJson(thread);
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
const view = (token: string) => wrap(
client,
<ThreadShell
session={session("token-media")}
title="Token media"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
null,
token,
);
const { rerender } = render(view("tok-old"));
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
const image = screen.getByRole("img", { name: "token-answer.png" });
rerender(view("tok-new"));
await act(async () => Promise.resolve());
expect(historyCalls).toBe(1);
expect(screen.getByRole("img", { name: "token-answer.png" })).toBe(image);
});
it("does not scroll again when canonical history refreshes after a session update", async () => {
@@ -3454,6 +3645,68 @@ describe("ThreadShell", () => {
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
});
it("does not let an older catalog request overwrite a newer install event", async () => {
const client = makeClient();
let resolveCatalog!: (response: Response) => void;
const pendingCatalog = new Promise<Response>((resolve) => {
resolveCatalog = resolve;
});
vi.mocked(fetch).mockImplementation((input) => {
if (String(input).includes("/api/settings/cli-apps?installed_only=1")) {
return pendingCatalog;
}
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({}),
} as Response);
});
render(wrap(
client,
<ThreadShell
session={session("chat-cli-race")}
title="Chat chat-cli-race"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={() => {}}
/>,
));
const input = await screen.findByLabelText("Message input");
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/settings/cli-apps?installed_only=1",
expect.anything(),
));
const payload: CliAppsPayload = {
apps: [{
name: "gimp",
display_name: "GIMP",
category: "image",
description: "Image editing",
requires: "",
source: "harness",
entry_point: "cli-anything-gimp",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: null,
brand_color: "#5C5543",
skill_installed: true,
}],
installed_count: 1,
catalog_updated_at: "2026-07-30",
};
await act(async () => {
window.dispatchEvent(new CustomEvent(CLI_APPS_CHANGED_EVENT, { detail: payload }));
resolveCatalog(httpJson({ apps: [], installed_count: 0 }));
await pendingCatalog;
});
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
});
it("keeps installed app mentions available during transient catalog refresh failures", async () => {
const client = makeClient();
const payload: CliAppsPayload = {