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:
@@ -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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user