perf(app): avoid stream updates in hidden chats (#7901)

Retained chats already preserve their last visible transcript. Keep their stream-tail selection stable while inactive so incoming chunks do not schedule React work; reveal reads the current model tail.
This commit is contained in:
Mohamed Boudra
2026-10-26 13:49:56 +07:00
committed by GitHub
parent 443f1501e4
commit 78a3083fea
3 changed files with 84 additions and 3 deletions
@@ -0,0 +1,12 @@
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useSessionStore } from "@/stores/session-store";
// The view retains its last visible transcript. Selecting a stable value here
// also prevents incoming chunks from scheduling React work in a hidden panel.
// Revealing the panel reads the current tail directly from the model.
export function useAgentStreamTail(serverId: string, agentId: string | undefined) {
const active = useRetainedPanelActive();
return useSessionStore((state) =>
active && agentId ? state.sessions[serverId]?.agentStreamTail.get(agentId) : undefined,
);
}
+2 -3
View File
@@ -20,6 +20,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useAgentStreamTail } from "@/agent-stream/use-agent-stream-tail";
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { ComposerDock } from "@/composer/dock";
@@ -1365,9 +1366,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
const bottomOverlayControlClearance = hasVisibleComposerTracks
? resolveComposerTrackControlClearance(isCompactFormFactor)
: 0;
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
);
const streamItemsRaw = useAgentStreamTail(serverId, agentId);
const pendingMessageSubmissions = useSessionStore(
useShallow((state) =>
agentId
@@ -1,11 +1,15 @@
import React, {
act,
Profiler,
useCallback,
useEffect,
useLayoutEffect,
useState,
useSyncExternalStore,
} from "react";
import { useAgentStreamTail } from "@/agent-stream/use-agent-stream-tail";
import { useSessionStore } from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel";
@@ -220,3 +224,69 @@ describe("workspace sheet environment", () => {
source.unmount();
});
});
for (const [runtime, Chat] of [
["native", NativeChat],
["web", WebChat],
] as const) {
it(`${runtime} ignores hidden stream chunks and reads the latest transcript on reveal`, async () => {
const serverId = `hidden-stream-${runtime}`;
const agentId = "background-agent";
const store = useSessionStore.getState();
store.initializeSession(serverId, null);
const profile = {
commits: 0,
onRender() {
profile.commits++;
},
};
const publish = (text: string) => {
const item: StreamItem = {
id: "message",
kind: "assistant_message",
text,
timestamp: new Date(),
};
store.setAgentStreamTail(serverId, new Map([[agentId, [item]]]));
};
function Content() {
const items = useAgentStreamTail(serverId, agentId);
const [draft] = useState("unsent draft");
return (
<span>
{draft}:{items?.[0]?.kind === "assistant_message" ? items[0].text : ""}
</span>
);
}
const show = (active: boolean) =>
act(() =>
root.render(
<Profiler id="workspace" onRender={profile.onRender}>
<RetainedPanelActivity active={active}>
<Chat>
<Content />
</Chat>
</RetainedPanelActivity>
</Profiler>,
),
);
try {
publish("first chunk");
show(true);
expect(container.textContent).toBe("unsent draft:first chunk");
show(false);
const hiddenCommits = profile.commits;
for (let index = 0; index < 10; index++) {
await act(async () => publish(`chunk ${index}`));
}
expect(profile.commits).toBe(hiddenCommits);
show(true);
expect(container.textContent).toBe("unsent draft:chunk 9");
await act(async () => publish("live after reveal"));
expect(container.textContent).toBe("unsent draft:live after reveal");
} finally {
act(() => root.render(null));
store.clearSession(serverId);
}
});
}