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);
+ }
+ });
+}