From 78a3083fea923959c039edf0dd608a1cd6166f7d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 13:49:56 +0700 Subject: [PATCH] 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. --- .../src/agent-stream/use-agent-stream-tail.ts | 12 ++++ packages/app/src/panels/agent-panel.tsx | 5 +- .../retained-chat-content.browser.test.tsx | 70 +++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/agent-stream/use-agent-stream-tail.ts diff --git a/packages/app/src/agent-stream/use-agent-stream-tail.ts b/packages/app/src/agent-stream/use-agent-stream-tail.ts new file mode 100644 index 0000000000..cd26faff47 --- /dev/null +++ b/packages/app/src/agent-stream/use-agent-stream-tail.ts @@ -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, + ); +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index ea7cc68b37..8139459b2b 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 diff --git a/packages/app/src/panels/retained-chat-content.browser.test.tsx b/packages/app/src/panels/retained-chat-content.browser.test.tsx index 07b1ceca75..57977dd462 100644 --- a/packages/app/src/panels/retained-chat-content.browser.test.tsx +++ b/packages/app/src/panels/retained-chat-content.browser.test.tsx @@ -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 ( + + {draft}:{items?.[0]?.kind === "assistant_message" ? items[0].text : ""} + + ); + } + const show = (active: boolean) => + act(() => + root.render( + + + + + + + , + ), + ); + 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); + } + }); +}