From c3f83d3922d4dcc4b46544d870182aab16291a2a Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 14 Oct 2026 11:26:57 +0700 Subject: [PATCH 1/2] 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 --- .../conversation/ConversationView.tsx | 39 ++++++-- .../components/conversation/MessageList.tsx | 49 ++++++++-- .../ConversationView.hidden.test.tsx | 96 +++++++++++++++++++ client/src/components/run/ChatOutline.tsx | 28 ++++-- client/src/components/run/LaneConsolePane.tsx | 15 ++- client/src/components/run/PaneChat.tsx | 26 +++-- client/src/components/run/TerminalView.tsx | 25 ++++- .../src/hooks/__tests__/useOnScreen.test.tsx | 56 +++++++++++ client/src/hooks/useOnScreen.ts | 42 ++++++++ 9 files changed, 337 insertions(+), 39 deletions(-) create mode 100644 client/src/components/conversation/__tests__/ConversationView.hidden.test.tsx create mode 100644 client/src/hooks/__tests__/useOnScreen.test.tsx create mode 100644 client/src/hooks/useOnScreen.ts diff --git a/client/src/components/conversation/ConversationView.tsx b/client/src/components/conversation/ConversationView.tsx index d02deb12..81c6cdf6 100644 --- a/client/src/components/conversation/ConversationView.tsx +++ b/client/src/components/conversation/ConversationView.tsx @@ -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. diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx index 80815a24..8919ffde 100644 --- a/client/src/components/conversation/MessageList.tsx +++ b/client/src/components/conversation/MessageList.tsx @@ -179,6 +179,17 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map; + +/** 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): 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({ ); } +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(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 ( -
+
{waiting ? ( @@ -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({
); -}); +} +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({
); -}); +} +const MessageRow = memo( + MessageRowView, + (p, n) => p.msg === n.msg && sameResults(n.msg.content, p.toolResultMap, n.toolResultMap) +); diff --git a/client/src/components/conversation/__tests__/ConversationView.hidden.test.tsx b/client/src/components/conversation/__tests__/ConversationView.hidden.test.tsx new file mode 100644 index 00000000..b711ffc4 --- /dev/null +++ b/client/src/components/conversation/__tests__/ConversationView.hidden.test.tsx @@ -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ĩ + */ + +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>(); + 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(); + 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(); + 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); + }); +}); diff --git a/client/src/components/run/ChatOutline.tsx b/client/src/components/run/ChatOutline.tsx index 75ea0bab..853acdb0 100644 --- a/client/src/components/run/ChatOutline.tsx +++ b/client/src/components/run/ChatOutline.tsx @@ -16,7 +16,14 @@ * @author Nguyễn Ngọc Trí Vĩ */ -import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react"; +import { + useEffect, + useState, + type ComponentType, + type DragEvent, + type ReactNode, + type RefObject, +} from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { useSettingsHref } from "../../lib/settingsLink"; @@ -35,6 +42,8 @@ import { X, } from "lucide-react"; import { api, type SessionOutline } from "../../lib/api"; +import { keepIfSame } from "../../lib/keepIfSame"; +import { isShown } from "../../hooks/useOnScreen"; import { displayName, extOf } from "./ChatFilePreview"; import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach"; @@ -49,25 +58,30 @@ const EMPTY: SessionOutline = { mcp: [], }; -/** The session's outline, re-read on a visibility-gated poll. */ -export function useSessionOutline(sessionId: string): SessionOutline { +/** The session's outline, re-read on a visibility-gated poll — skipped too + * while `onScreen` says the pane is hidden (another workspace or tab, kept + * mounted). An unchanged answer keeps the old object: no re-render. */ +export function useSessionOutline( + sessionId: string, + onScreen?: RefObject +): SessionOutline { const [outline, setOutline] = useState(EMPTY); useEffect(() => { let live = true; const load = () => api.sessions .outline(sessionId) - .then((o) => live && setOutline({ ...EMPTY, ...o })) + .then((o) => live && setOutline(keepIfSame({ ...EMPTY, ...o }))) .catch(() => {}); void load(); const timer = window.setInterval(() => { - if (document.visibilityState === "visible") void load(); + if (onScreen ? isShown(onScreen) : document.visibilityState === "visible") void load(); }, POLL_MS); return () => { live = false; window.clearInterval(timer); }; - }, [sessionId]); + }, [sessionId, onScreen]); return outline; } @@ -271,7 +285,7 @@ export function ChatOutline({ ) : ( )} */ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, Plus } from "lucide-react"; import { api } from "../../lib/api"; @@ -89,6 +89,13 @@ export function LaneConsolePane({ : null; const chat = useSessionChat(chatSession); const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool); + // Stable, so the memoized terminal is not re-rendered for a new closure. + const onRunExitRef = useRef(onRunExit); + onRunExitRef.current = onRunExit; + const onTerminalExit = useCallback(() => { + setHandle(null); + onRunExitRef.current?.(); + }, []); useEffect(() => { onHasActiveRunChange?.(handle !== null); @@ -210,13 +217,11 @@ export function LaneConsolePane({ runId={handle.id} wsBaseUrl={window.location.origin.replace(/^http/, "ws")} autoFocus={autoFocus && !(chatable && chat)} + covered={!!(chatable && chat)} // The pane died (double Ctrl+C out of `claude`, `exit`, a crash) — // the tab bar moves on (another tab, or the home's prompt box) // rather than leaving a dead terminal the user has to click out of. - onExit={() => { - setHandle(null); - onRunExit?.(); - }} + onExit={onTerminalExit} /> )} diff --git a/client/src/components/run/PaneChat.tsx b/client/src/components/run/PaneChat.tsx index 418b5e49..b10f7062 100644 --- a/client/src/components/run/PaneChat.tsx +++ b/client/src/components/run/PaneChat.tsx @@ -54,7 +54,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ -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(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({ )} ); -} +}); diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 6be9a978..33d99d18 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -42,6 +42,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ import { + memo, useEffect, useId, useMemo, @@ -153,9 +154,22 @@ interface TerminalViewProps { /** Take the keyboard when it appears (default). Off for a split pane that * is not the focused one, or keys would land in whichever mounted last. */ autoFocus?: boolean; + /** Another layer (the pane's chat) covers it. xterm stops drawing only when + * its IntersectionObserver says off screen, and a covered terminal is not — + * so it is moved out of the viewport with a transform, which keeps its + * layout size (no PTY resize; the chat still reads and answers its menus). */ + covered?: boolean; } -export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: TerminalViewProps) { +/** Memoized: the Workspace re-renders its pane tree on every lane_update, and + * a terminal only cares about its own props (its owner keeps `onExit` stable). */ +export const TerminalView = memo(function TerminalView({ + runId, + wsBaseUrl, + onExit, + autoFocus = true, + covered = false, +}: TerminalViewProps) { const containerRef = useRef(null); // Read through a ref: an inline `onExit` from the parent changes identity on // every render, and as an effect dep it would tear the terminal down and @@ -709,7 +723,12 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter onPasteCapture={onPaste} onFocus={() => notePaneFocus(runId)} > -
+

{t("terminal.keyboardHint")}

@@ -810,4 +829,4 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter )}
); -} +}); diff --git a/client/src/hooks/__tests__/useOnScreen.test.tsx b/client/src/hooks/__tests__/useOnScreen.test.tsx new file mode 100644 index 00000000..e59e32b9 --- /dev/null +++ b/client/src/hooks/__tests__/useOnScreen.test.tsx @@ -0,0 +1,56 @@ +/** + * @file useOnScreen.test.tsx + * @description useOnScreen follows the IntersectionObserver: on screen by + * default (and always without an observer, as in jsdom), off once the element + * stops intersecting, and `onShow` runs only on the way back on screen. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { act, render } from "@testing-library/react"; +import { useRef } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useOnScreen, isShown } from "../useOnScreen"; + +const original = globalThis.IntersectionObserver; +afterEach(() => { + globalThis.IntersectionObserver = original; +}); + +let seen: { current: boolean | null } = { current: null }; +function Probe({ onShow }: { onShow?: () => void }) { + const ref = useRef(null); + seen = useOnScreen(ref, onShow); + return
; +} + +describe("useOnScreen", () => { + it("counts as on screen without an IntersectionObserver", () => { + // @ts-expect-error removed for the test + delete globalThis.IntersectionObserver; + render(); + expect(seen.current).toBe(true); + expect(isShown(seen as never)).toBe(true); + }); + + it("follows the observer and calls onShow only when it comes back", () => { + let fire: (v: boolean) => void = () => {}; + // @ts-expect-error test stub + globalThis.IntersectionObserver = class { + constructor(cb: (e: { isIntersecting: boolean }[]) => void) { + fire = (v) => cb([{ isIntersecting: v }]); + } + observe() {} + disconnect() {} + }; + const onShow = vi.fn(); + render(); + act(() => fire(true)); + expect(onShow).not.toHaveBeenCalled(); + act(() => fire(false)); + expect(seen.current).toBe(false); + expect(isShown(seen as never)).toBe(false); + act(() => fire(true)); + expect(seen.current).toBe(true); + expect(onShow).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/hooks/useOnScreen.ts b/client/src/hooks/useOnScreen.ts new file mode 100644 index 00000000..47963a17 --- /dev/null +++ b/client/src/hooks/useOnScreen.ts @@ -0,0 +1,42 @@ +/** + * @file useOnScreen — whether an element is laid out on screen right now, for + * work that only matters while it can be seen. The Workspace keeps panes of + * other workspaces and other tabs MOUNTED but `display:none` (unmounting would + * drop a live terminal), so `document.visibilityState` alone keeps every hidden + * pane polling and re-rendering as if it were shown. An IntersectionObserver + * reports a `display:none` subtree as not intersecting; `onShow` runs when the + * element comes back, so a caller can catch up once instead of all the time. + * Without IntersectionObserver (jsdom, very old browsers) the element counts + * as always on screen — the behaviour before this hook existed. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useRef, type RefObject } from "react"; + +export function useOnScreen( + ref: RefObject, + onShow?: () => void +): RefObject { + const visible = useRef(true); + const showRef = useRef(onShow); + showRef.current = onShow; + + useEffect(() => { + const el = ref.current; + if (!el || typeof IntersectionObserver === "undefined") return; + const io = new IntersectionObserver((entries) => { + const now = entries[entries.length - 1]!.isIntersecting; + const was = visible.current; + visible.current = now; + if (now && !was) showRef.current?.(); + }); + io.observe(el); + return () => io.disconnect(); + }, [ref]); + + return visible; +} + +/** True while the page is shown AND the element is on screen. */ +export const isShown = (onScreen: RefObject) => + document.visibilityState === "visible" && onScreen.current !== false; From 1c5521cf2ec16a5f9f3e8fad5cfbc1c1a8509d23 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 14 Oct 2026 11:55:53 +0700 Subject: [PATCH 2/2] perf: rest animations, cheaper chat typing and server polls, async per-tool hooks UI - No animation runs forever: Sagi moods, the custom pet sprite, status dots (pulse-dot, pulse-slow, ping) and the chat shimmer play a few cycles and rest. An infinite animation kept the browser drawing 60 frames a second. - useSagiBrain: frames that only move lastActivityAt by <1 s keep the old state; the 1 s tick re-renders only when the mood changes. - ConversationView and MessageList are memoized and PaneChat passes stable callbacks: typing in the chat box no longer re-renders the conversation. - The chat's 700 ms menu poll reads plain text first and scans the terminal cell by cell (reusing one cell) only when a row could be a menu cursor row. Server - GET /sessions/:id/transcript?after=N skips lines <= N before JSON.parse. - GET /sessions/:id/outline is cached until the session's events change. - broadcastLane uses the cached tmux.liveSessions(); tmux.hasSession answers "yes" from the fresh shared list instead of a synchronous spawn (50-150 ms per psmux call on Windows, blocking terminal echo). - pane-mode polls every 150 ms; writeHourly writes only changed hour buckets. - /api/metrics: agenthub_event_loop_delay_seconds{quantile}. Hooks - PreToolUse/PostToolUse run with "async": true (settings installer, plugin.json, S3 managed settings): Claude Code no longer waits a Node start before and after every tool call. Committed with --no-verify at the owner's request: the pre-commit full server suite fails on 9 tests that fail identically on 0c2f498 (lane-sync merge: no git identity on this host; read-pool; resolveHost; codex-usage 1 ms flake). Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/plugin.json | 6 ++- client/src/components/Sagi/PetSprite.tsx | 8 +++- client/src/components/Sagi/sagi.css | 26 +++++++----- client/src/components/Sagi/useSagiBrain.ts | 30 ++++++++++++-- .../conversation/ConversationView.tsx | 8 +++- .../components/conversation/MessageList.tsx | 5 ++- client/src/components/run/PaneChat.tsx | 22 ++++++---- client/src/components/run/TerminalView.tsx | 17 ++++++-- client/src/index.css | 14 +++++-- client/src/lib/__tests__/panePaste.test.ts | 15 +++++++ client/src/lib/panePaste.ts | 11 +++-- client/src/pages/Dashboard.tsx | 2 +- client/tailwind.config.js | 5 ++- docs/API.md | 1 + docs/HOOKS.md | 4 +- docs/LANES.md | 2 +- docs/MONITORING.md | 3 +- docs/SERVER-MODE.md | 4 +- scripts/install-hooks.js | 17 +++++++- scripts/server/install-system.sh | 5 ++- server/__tests__/daily-cost.test.js | 40 +++++++++++++++++++ server/__tests__/install-hooks.test.js | 13 ++++++ server/__tests__/metrics.test.js | 1 + server/__tests__/tmux.test.js | 15 +++++++ server/lib/hourly-usage.js | 31 +++++++++++--- server/lib/pane-mode.js | 7 +++- server/lib/tmux.js | 14 +++++++ server/routes/lanes.js | 6 ++- server/routes/metrics.js | 33 +++++++++++++++ server/routes/sessions.js | 34 ++++++++++++---- server/routes/settings.js | 3 ++ 31 files changed, 341 insertions(+), 61 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 354e9c9c..6cff9a7b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -22,7 +22,8 @@ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse", - "timeout": 5 + "timeout": 5, + "async": true } ] } @@ -34,7 +35,8 @@ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse", - "timeout": 5 + "timeout": 5, + "async": true } ] } diff --git a/client/src/components/Sagi/PetSprite.tsx b/client/src/components/Sagi/PetSprite.tsx index 95b1c5f1..38e74ce4 100644 --- a/client/src/components/Sagi/PetSprite.tsx +++ b/client/src/components/Sagi/PetSprite.tsx @@ -58,6 +58,8 @@ const ROW: Record = { /** What the user is doing to the pet, which outranks its mood. */ export type PetAction = "run-left" | "run-right" | "wave"; const ACTION_ROW: Record = { "run-right": 1, "run-left": 2, wave: 3 }; +/** Loops of a row before the sprite rests on its first frame. */ +const SPRITE_LOOPS = 4; export function PetSprite({ id, @@ -95,13 +97,17 @@ export function PetSprite({ const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1); let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own; let i = 0; + let loops = 0; let timer = 0; - // `i` always indexes `list`: it wraps at its end. + // `i` always indexes `list`: it wraps at its end. A few loops, then the + // first frame rests — a sprite that never stops re-paints the page several + // times a second for as long as it is open; a new mood or action plays again. const tick = () => { timer = window.setTimeout(() => { if (++i === list.length) { i = 0; if (MOMENTS.has(row)) list = play(0); + if (++loops >= SPRITE_LOOPS) return setCell(list[0]!); } setCell(list[i]!); tick(); diff --git a/client/src/components/Sagi/sagi.css b/client/src/components/Sagi/sagi.css index a728f7c1..36609679 100644 --- a/client/src/components/Sagi/sagi.css +++ b/client/src/components/Sagi/sagi.css @@ -1,8 +1,12 @@ /** * sagi.css - animations + mood expressions for the Sagi companion. * Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale) - * with warm pink accents (ears, cheeks, nose) for cuteness. All continuous - * motion is disabled under [data-reduced="1"] and prefers-reduced-motion. + * with warm pink accents (ears, cheeks, nose) for cuteness. No mood animates + * forever: each plays a few cycles when the mood begins and then rests — an + * infinite animation makes the browser draw a new frame 60 times a second for + * as long as the page is open (the drop-shadow filter re-rendered each time), + * which made the whole desktop stutter. All motion is disabled under + * [data-reduced="1"] and prefers-reduced-motion. * Transform origins are the delicate part - see the comments on .sagi-avatar * and .sagi-eye-blink for which reference box each one resolves against. * @author Nguyễn Ngọc Trí Vĩ @@ -206,20 +210,20 @@ /* idle: gentle breathing + occasional blink */ .sagi-avatar[data-mood="idle"] { - animation: sagi-breathe 4s ease-in-out infinite; + animation: sagi-breathe 4s ease-in-out 0s 3; } /* Blink lives on the inner group so it never overrides the outer group's eye-tracking translate (which would freeze the eyes - see the CSS-blink note in SagiAvatar.tsx). */ .sagi-avatar[data-mood="idle"] .sagi-eye-blink { - animation: sagi-blink 5s ease-in-out infinite; + animation: sagi-blink 5s ease-in-out 0s 3; } /* happy: whole-mascot bob + twinkling sparkle */ .sagi-avatar[data-mood="happy"] { animation: sagi-bob 0.5s ease-in-out 0s 4; } .sagi-avatar[data-mood="happy"] .sagi-sparkle { - animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite; + animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4; transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */ } /* worried: alarmed shake */ @@ -228,19 +232,19 @@ } /* stuck: slow, drooping breathe (fatigued) */ .sagi-avatar[data-mood="stuck"] { - animation: sagi-droop 2.4s ease-in-out infinite; + animation: sagi-droop 2.4s ease-in-out 0s 3; } /* thinking: rocking head tilt */ .sagi-avatar[data-mood="thinking"] { - animation: sagi-rock 1.8s ease-in-out infinite; + animation: sagi-rock 1.8s ease-in-out 0s 4; } /* watching: attentive sway while a session is live */ .sagi-avatar[data-mood="watching"] { - animation: sagi-sway 3.2s ease-in-out infinite; + animation: sagi-sway 3.2s ease-in-out 0s 3; } /* sleeping: slow breathe, droop */ .sagi-avatar[data-mood="sleeping"] { - animation: sagi-breathe 5s ease-in-out infinite; + animation: sagi-breathe 5s ease-in-out 0s 3; } /* ── reduced motion: kill all continuous animation ── */ @@ -339,7 +343,7 @@ align-items: center; justify-content: center; border: 2px solid #0c0c14; - animation: sagi-error-pulse 1.2s ease-in-out infinite; + animation: sagi-error-pulse 1.2s ease-in-out 0s 5; } .sagi-bubble { max-width: 16rem; @@ -375,7 +379,7 @@ /* An uploaded still image has no frames of its own: it bobs, faster while an agent works and slower asleep. A Codex spritesheet animates by its frames. */ .sagi-pet-image { - animation: sagi-pet-bob 2.6s ease-in-out infinite; + animation: sagi-pet-bob 2.6s ease-in-out 0s 3; -webkit-user-drag: none; } .sagi-pet-image[data-mood="thinking"] { diff --git a/client/src/components/Sagi/useSagiBrain.ts b/client/src/components/Sagi/useSagiBrain.ts index e34ac9bd..37ff132f 100644 --- a/client/src/components/Sagi/useSagiBrain.ts +++ b/client/src/components/Sagi/useSagiBrain.ts @@ -77,6 +77,17 @@ import { import { pickQuip } from "./quips"; import { sagiPrefs } from "./prefs"; +/** `next` differs from `prev` only by a lastActivityAt less than a second + * newer — nothing the mascot shows, at the moods' 1 s resolution. */ +function onlyActivityMoved(prev: SagiState, next: SagiState): boolean { + if (next === prev) return true; + if (next.lastActivityAt - prev.lastActivityAt >= 1000) return false; + for (const k of Object.keys(next) as (keyof SagiState)[]) { + if (k !== "lastActivityAt" && next[k] !== prev[k]) return false; + } + return true; +} + const BUBBLE_MS = 4500; // Minimum gap between non-error bubbles, so a burst of activity doesn't spam. const BUBBLE_THROTTLE_MS = 3000; @@ -153,7 +164,10 @@ export function useSagiBrain(): SagiBrain { setState((prev) => { const { state: next, pulse } = reduceSagi(prev, msg, t); if (pulse) showBubble(pickQuip(pulse), pulse === "error"); - return next; + // Most frames only move lastActivityAt, which the moods read at a 1 s + // resolution: keep the old state then, so a hook burst (several + // frames a second) does not re-render the mascot on every frame. + return onlyActivityMoved(prev, next) ? prev : next; }); }); const unsubConn = eventBus.onConnection((connected) => { @@ -169,15 +183,25 @@ export function useSagiBrain(): SagiBrain { // happy/worried) re-evaluate without needing a new event. useEffect(() => { // Hidden tab: no re-render; the first visible tick catches the mood up. + // Only a tick that changes the mood re-renders: an idle mascot is not + // drawn again every second for nothing. const id = setInterval(() => { - if (document.visibilityState === "visible") setTick(Date.now()); + if (document.visibilityState !== "visible") return; + const now = Date.now(); + if (deriveMood(stateRef.current, now) !== moodRef.current) setTick(now); }, 1000); return () => clearInterval(id); }, []); useEffect(() => () => clearTimeout(bubbleTimer.current), []); - const mood = useMemo(() => deriveMood(state, tick), [state, tick]); + // Judged at render time: `tick` only moves when the mood would change, so + // it may be old when a frame re-renders. + const mood = useMemo(() => deriveMood(state, Math.max(tick, Date.now())), [state, tick]); + const stateRef = useRef(state); + stateRef.current = state; + const moodRef = useRef(mood); + moodRef.current = mood; const status = useMemo(() => statusOf(state), [state]); const dismissBubble = useCallback(() => { diff --git a/client/src/components/conversation/ConversationView.tsx b/client/src/components/conversation/ConversationView.tsx index 81c6cdf6..2df66a35 100644 --- a/client/src/components/conversation/ConversationView.tsx +++ b/client/src/components/conversation/ConversationView.tsx @@ -61,7 +61,7 @@ * * ----------------------------------------------------------------------------- */ -import { useEffect, useState, useCallback, useMemo, useRef } from "react"; +import { memo, 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"; @@ -112,7 +112,7 @@ interface ConversationViewProps { onNotStarted?: (notStarted: boolean) => void; } -export function ConversationView({ +function ConversationViewPane({ sessionId, initialTranscriptId, fill = false, @@ -646,3 +646,7 @@ export function ConversationView({
); } + +/** Memoized: a chat's prompt box lives in its parent (PaneChat), and every + * keystroke there re-rendered the whole conversation. */ +export const ConversationView = memo(ConversationViewPane); diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx index 8919ffde..813802e5 100644 --- a/client/src/components/conversation/MessageList.tsx +++ b/client/src/components/conversation/MessageList.tsx @@ -633,7 +633,7 @@ const EditedFiles = memo( sameResults(n.blocks, p.toolResultMap, n.toolResultMap) ); -export function MessageList({ +function MessageListView({ messages, loading, onOpenFile, @@ -1550,3 +1550,6 @@ const MessageRow = memo( MessageRowView, (p, n) => p.msg === n.msg && sameResults(n.msg.content, p.toolResultMap, n.toolResultMap) ); + +/** Memoized: its owner re-renders on scroll state and its parent's typing. */ +export const MessageList = memo(MessageListView); diff --git a/client/src/components/run/PaneChat.tsx b/client/src/components/run/PaneChat.tsx index b10f7062..aa638b67 100644 --- a/client/src/components/run/PaneChat.tsx +++ b/client/src/components/run/PaneChat.tsx @@ -467,6 +467,19 @@ export const PaneChat = memo(function PaneChat({ [] ); + // Stable, so typing in the prompt box does not re-render the conversation + // (ConversationView is memoized). + const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]); + const openFile = useCallback( + (path: string) => { + // The workspace's Changes panel when the file is in its folder; + // else this view's own preview. + if (laneId !== null && openInChanges(laneId, cwd, path)) return; + setPreview(path); + }, + [laneId, cwd] + ); + // Before the session starts the CLI may be asking in the terminal (trust // this folder? with "No, exit" preselected): an Enter from here answers it. // Asked, not refused: whether the session row appears right after the @@ -515,15 +528,10 @@ export const PaneChat = memo(function PaneChat({ follow={follow} waiting={asking} stopped={stops} - onOpenTerminal={() => showTerminal(runId, sessionId)} + onOpenTerminal={openTerminal} onContext={onContext} onNotStarted={setNotStarted} - onOpenFile={(path) => { - // The workspace's Changes panel when the file is in its folder; - // else this view's own preview. - if (laneId !== null && openInChanges(laneId, cwd, path)) return; - setPreview(path); - }} + onOpenFile={openFile} /> {!outlineOpen && !preview && (