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;