Files
AgentHub/client/src/components/conversation/__tests__/ConversationView.hidden.test.tsx
T
nntrivi2001 c3f83d3922 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>
2026-10-14 11:26:57 +07:00

97 lines
3.0 KiB
TypeScript

/**
* @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);
});
});