c3f83d3922
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>
97 lines
3.0 KiB
TypeScript
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);
|
|
});
|
|
});
|