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 55e62dd8..d9d0aa66 100644 --- a/client/src/components/Sagi/PetSprite.tsx +++ b/client/src/components/Sagi/PetSprite.tsx @@ -63,6 +63,8 @@ const ACTION_ROW: Record = { "run-right": 1, "run-left": 2, w /** A moment's row: wave, jump, failed, waiting, review. It outranks the mood, * and the user's own action outranks it. */ const MOMENT_ROW: Record = { wave: 3, jump: 4, fail: 5, wait: 6, review: 8 }; +/** Loops of a row before the sprite rests on its first frame. */ +const SPRITE_LOOPS = 4; export function PetSprite({ id, @@ -102,13 +104,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 31fdbb9e..03f8427a 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; } /* ── moments: one-shot reactions to an event or a pat (useSagiBrain), played @@ -267,7 +271,7 @@ animation: sagi-peek 2.2s ease-in-out 0s 1; } .sagi-avatar[data-moment="jump"] .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; } @keyframes sagi-hop { @@ -417,7 +421,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; } /* Sessions waiting on the user, when none errored: the same dot, amber. */ .sagi-error-dot[data-kind="waiting"] { @@ -496,7 +500,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 68d64ed8..97a7b9f6 100644 --- a/client/src/components/Sagi/useSagiBrain.ts +++ b/client/src/components/Sagi/useSagiBrain.ts @@ -90,6 +90,17 @@ import { greetingKey, pickLine, pickQuip } from "./quips"; import { statusSummary } from "./intents"; 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; // A bubble that names a session stays long enough to click it. const SUBJECT_BUBBLE_MS = 7000; @@ -283,8 +294,12 @@ export function useSagiBrain(): SagiBrain { // Subscribe to the live stream and connection status. useEffect(() => { const unsubMsg = eventBus.subscribe((msg: WSMessage) => { - const { state: next, pulse, subject } = reduceSagi(stateRef.current, msg, Date.now()); - update(() => next); + const prev = stateRef.current; + const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now()); + // 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. + if (!onlyActivityMoved(prev, next)) update(() => next); if (pulse) notify(pulse, subject); }); const unsubConn = eventBus.onConnection((connected) => { @@ -300,8 +315,12 @@ 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); }, []); @@ -315,7 +334,9 @@ export function useSagiBrain(): SagiBrain { [] ); - 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 status = useMemo(() => statusOf(state), [state]); const attention = useMemo(() => attentionOf(state), [state]); diff --git a/client/src/components/conversation/ConversationView.tsx b/client/src/components/conversation/ConversationView.tsx index d02deb12..2df66a35 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 { 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"; 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; @@ -106,7 +112,7 @@ interface ConversationViewProps { onNotStarted?: (notStarted: boolean) => void; } -export function ConversationView({ +function ConversationViewPane({ sessionId, initialTranscriptId, fill = false, @@ -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. @@ -627,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 80815a24..813802e5 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,8 +625,15 @@ 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({ +function MessageListView({ messages, loading, onOpenFile, @@ -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,11 @@ const MessageRow = memo(function MessageRow({
); -}); +} +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/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 00b26508..8949aa26 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 @@ export 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,10 +460,26 @@ 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 } + ), [] ); + // 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 @@ -503,15 +528,10 @@ export 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 && (