perf(chat): hidden panes stop polling, rows stop re-rendering, a covered terminal stops drawing

The Workspace keeps panes of other workspaces and tabs mounted (display:none),
and every one of them kept following the live stream as if shown.

- useOnScreen (IntersectionObserver): ConversationView, the outline and the
  700 ms menu scan skip while their pane is hidden and catch up once on show.
- A new_event refetches the tail at most every 1.5 s (leading + trailing).
- MessageList: WorkGroup/MessageRow/EditedFiles compare only their own blocks
  and results, so a tail append no longer re-renders every row.
- A terminal under the chat is moved off the viewport with a transform, so
  xterm pauses drawing without a resize.
- PaneChat and TerminalView are memoized; LaneConsolePane passes a stable onExit.
- RunningStep's clock and the outline poll skip unchanged or hidden updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 11:26:57 +07:00
parent 0c2f49876d
commit c3f83d3922
9 changed files with 337 additions and 39 deletions
@@ -61,13 +61,15 @@
*
* ----------------------------------------------------------------------------- */
import { useEffect, useState, useCallback, useRef } from "react";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList, promptTokens } from "./MessageList";
import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
@@ -80,6 +82,10 @@ const POLL_INTERVAL_MS = 3000;
// Rescan the transcripts list periodically so new subagents that spawn
// mid-session appear in the dropdown without a page reload.
const TRANSCRIPTS_REFRESH_MS = 15000;
// A hook burst (Pre + Post per tool call, several sessions) refetches the tail
// at most this often: the first event still fetches at once, the last is never
// lost, and a long transcript is not re-read for every single frame.
const EVENT_FETCH_MS = 1500;
interface ConversationViewProps {
sessionId: string;
@@ -161,6 +167,10 @@ export function ConversationView({
// Bumped whenever the session or the selected transcript changes: a tail
// fetch started for the previous transcript must not append to the new one.
const genRef = useRef(0);
// A pane of another workspace or tab stays mounted but hidden: it neither
// polls nor follows events, and catches up once when it is shown again.
const onShowRef = useRef<() => void>(() => {});
const onScreen = useOnScreen(scrollContainerRef, () => onShowRef.current());
// Refresh-button spinner state - separate from initial `loading` so the
// existing skeleton doesn't blink during a manual refresh.
@@ -179,7 +189,7 @@ export function ConversationView({
}
loadTranscripts();
const interval = window.setInterval(() => {
if (document.visibilityState === "visible") loadTranscripts();
if (isShown(onScreen)) loadTranscripts();
}, TRANSCRIPTS_REFRESH_MS);
return () => {
cancelled = true;
@@ -299,24 +309,33 @@ export function ConversationView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, selectedTranscript]);
// WebSocket subscription: refetch on every new_event for this session.
// WebSocket subscription: refetch on new_event for this session, throttled.
// Hook coverage isn't complete (a user-typed message fires no hook), so we
// also poll below to catch what WS misses.
const fetchSoon = useMemo(
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
[fetchNewMessages]
);
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
// Hidden tab: the visibility handler below catches up once on show.
if (document.visibilityState !== "visible") return;
// Hidden tab or hidden pane: catching up once on show is enough.
if (!isShown(onScreen)) return;
if (isRemoteDataRefreshMessage(msg)) {
fetchNewMessages();
fetchSoon();
return;
}
if (msg.type !== "new_event") return;
const data = msg.data as { session_id?: string };
if (data.session_id !== sessionId) return;
fetchNewMessages();
fetchSoon();
});
return unsubscribe;
}, [sessionId, fetchNewMessages]);
}, [sessionId, fetchSoon, onScreen]);
onShowRef.current = () => {
if (document.visibilityState === "visible") void fetchNewMessages();
};
// Resync on WebSocket reconnect: events that landed during a transient
// disconnect are gone from the bus, but the JSONL still has them, so a
@@ -337,7 +356,7 @@ export function ConversationView({
function start() {
if (interval !== null) return;
interval = window.setInterval(() => {
if (document.visibilityState === "visible") fetchNewMessages();
if (isShown(onScreen)) fetchNewMessages();
}, POLL_INTERVAL_MS);
}
function stop() {
@@ -363,7 +382,7 @@ export function ConversationView({
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [fetchNewMessages]);
}, [fetchNewMessages, onScreen]);
// Manual refresh - surfaces a control in the toolbar so users can force
// a sync without reloading the page.
@@ -179,6 +179,17 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
return map;
}
type ResultMap = ReturnType<typeof buildToolResultMap>;
/** The map is rebuilt on every tail append, but a block's own result keeps its
* identity (old messages are kept, not copied): comparing just those lets a
* memoized row skip the re-render when nothing it shows changed. */
const sameResults = (blocks: TranscriptContent[], a: ResultMap, b: ResultMap) =>
a === b || blocks.every((bl) => !bl.id || a.get(bl.id) === b.get(bl.id));
const sameBlocks = (a: TranscriptContent[], b: TranscriptContent[]) =>
a === b || (a.length === b.length && a.every((bl, i) => bl === b[i]));
/** The whole prompt a message was answered from: `input_tokens` is only its
* uncached tail (a few tokens once the cache is warm), the rest is cache. */
export function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
@@ -544,7 +555,7 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
* the lines added and removed. A call still running or failed is left out. */
function EditedFiles({
function EditedFilesCard({
blocks,
toolResultMap,
onOpenFile,
@@ -614,6 +625,13 @@ function EditedFiles({
</div>
);
}
const EditedFiles = memo(
EditedFilesCard,
(p, n) =>
p.onOpenFile === n.onOpenFile &&
sameBlocks(p.blocks, n.blocks) &&
sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
);
export function MessageList({
messages,
@@ -870,8 +888,16 @@ function RunningStep({
const { t } = useTranslation("sessions");
const [now, setNow] = useState(() => Date.now());
const [showAll, setShowAll] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
// A hidden tab or a hidden pane (another workspace, kept mounted) shows
// no clock: skip the re-render until it is on screen again.
const timer = window.setInterval(() => {
const el = boxRef.current;
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
return;
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, []);
const counted = useCountUp(tokens);
@@ -893,7 +919,7 @@ function RunningStep({
].filter(Boolean);
const amber = waiting || quiet;
return (
<div data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
<div ref={boxRef} data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{waiting ? (
<Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" />
@@ -1369,7 +1395,7 @@ function WorkStep({
/** What the assistant did between two replies as one line, opening onto
* one framed list of its steps. */
const WorkGroup = memo(function WorkGroup({
function WorkGroupLine({
blocks,
toolResultMap,
}: {
@@ -1391,11 +1417,16 @@ const WorkGroup = memo(function WorkGroup({
</Disclosure>
</div>
);
});
}
const WorkGroup = memo(
WorkGroupLine,
(p, n) =>
sameBlocks(p.blocks, n.blocks) && sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
);
/** One user/assistant row. Memoized: a live tail append re-renders only the
* new rows; its thinking-block expansion is its own state, kept by key. */
const MessageRow = memo(function MessageRow({
function MessageRowView({
msg,
toolResultMap,
}: {
@@ -1514,4 +1545,8 @@ const MessageRow = memo(function MessageRow({
</div>
</div>
);
});
}
const MessageRow = memo(
MessageRowView,
(p, n) => p.msg === n.msg && sameResults(n.msg.content, p.toolResultMap, n.toolResultMap)
);
@@ -0,0 +1,96 @@
/**
* @file ConversationView.hidden.test.tsx
* @description A chat kept mounted in a hidden pane (another workspace or
* tab, `display:none`) does not follow the live stream: a new_event for its
* session fetches nothing while its box is off screen, and showing it again
* catches the tail up once. On screen, a burst of events is throttled to one
* fetch at once plus one after the burst.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
import { api } from "../../../lib/api";
import { eventBus } from "../../../lib/eventBus";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
sessions: {
transcripts: vi.fn(async () => ({ transcripts: [] })),
transcript: vi.fn(async () => ({
messages: [
{
type: "user",
content: [{ type: "text", text: "hello" }],
timestamp: "2026-10-09T00:00:00Z",
},
],
total: 1,
has_more: false,
first_line: 1,
last_line: 1,
})),
},
},
};
});
let intersect: ((visible: boolean) => void) | null = null;
const original = globalThis.IntersectionObserver;
beforeEach(() => {
intersect = null;
// @ts-expect-error test stub
globalThis.IntersectionObserver = class {
constructor(cb: (e: { isIntersecting: boolean }[]) => void) {
intersect = (visible) => cb([{ isIntersecting: visible }]);
}
observe() {}
disconnect() {}
};
});
afterEach(() => {
globalThis.IntersectionObserver = original;
vi.useRealTimers();
vi.clearAllMocks();
});
const event = () =>
act(() => eventBus.publish({ type: "new_event", data: { session_id: "s1" } } as never));
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
describe("ConversationView in a hidden pane", () => {
it("fetches nothing while hidden and catches up once when shown", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const before = calls();
act(() => intersect?.(false));
event();
event();
expect(calls()).toBe(before);
act(() => intersect?.(true));
await act(async () => {});
expect(calls()).toBe(before + 1);
});
it("throttles a burst of events on screen", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
vi.useFakeTimers();
const before = calls();
event();
event();
event();
// The first fetches at once; the rest wait for the throttle window.
expect(calls()).toBe(before + 1);
await act(async () => {
await vi.advanceTimersByTimeAsync(1600);
});
expect(calls()).toBe(before + 2);
});
});