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
+19 -7
View File
@@ -54,7 +54,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
@@ -76,6 +76,7 @@ import {
type PaneMenu,
} from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
@@ -306,7 +307,10 @@ function PaneMenuCard({
);
}
export function PaneChat({
/** Memoized: a lane_update re-renders the Workspace's pane tree, and every
* prop here is a primitive or the pane's own run handle — nothing in a
* frame about another lane reaches the chat. */
export const PaneChat = memo(function PaneChat({
run,
sessionId,
cwd,
@@ -370,7 +374,9 @@ export function PaneChat({
return () => ro.disconnect();
}, []);
const [list, setList] = useState<OutlineList | null>(null);
const activity = useSessionOutline(sessionId);
// Kept mounted in a hidden workspace or tab: its pollers skip until shown.
const onScreen = useOnScreen(rootRef);
const activity = useSessionOutline(sessionId, onScreen);
// Open by default only in a spanning pane, and only once there is an output.
const outlineOpen = outlineChoice ?? (panelOpen && activity.outputs.length > 0);
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
@@ -421,9 +427,12 @@ export function PaneChat({
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
});
read();
const timer = window.setInterval(read, 700);
// Nobody can answer a menu in a hidden tab or pane: no screen scan then.
const timer = window.setInterval(() => {
if (isShown(onScreen)) read();
}, 700);
return () => window.clearInterval(timer);
}, [runId]);
}, [runId, onScreen]);
const asking = waiting || menu !== null;
// Gone until the pane redraws: no second press on a stale menu.
const choose = (index: number) => {
@@ -451,7 +460,10 @@ export function PaneChat({
model: null,
});
const onContext = useCallback(
(tokens: number, model: string | null) => setContext({ tokens, model }),
(tokens: number, model: string | null) =>
setContext((prev) =>
prev.tokens === tokens && prev.model === model ? prev : { tokens, model }
),
[]
);
@@ -678,4 +690,4 @@ export function PaneChat({
)}
</div>
);
}
});