diff --git a/packages/core/src/agent-chat/agentChatService.ts b/packages/core/src/agent-chat/agentChatService.ts index a6af145eb4..bd92636378 100644 --- a/packages/core/src/agent-chat/agentChatService.ts +++ b/packages/core/src/agent-chat/agentChatService.ts @@ -1,4 +1,5 @@ import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import type { AcpMessage } from "@posthog/shared"; import type { AgentSessionEvent, DecideApprovalRequest, @@ -54,6 +55,15 @@ const MAX_LISTEN_RECONNECTS = 6; /** Reserve a margin so we mint a fresh token before the server rejects the old one. */ const PREVIEW_TOKEN_EARLY_REFRESH_MS = 30_000; +/** + * Coalesce mapped stream messages into at most one store write per frame instead + * of one per SSE event. The local-session path batches the same way + * (`SESSION_EVENT_FLUSH_MS`); without it, a fast agent stream fires a full + * agentChatStore state clone — and a re-render of every chat subscriber — on + * every token. + */ +const AGENT_CHAT_FLUSH_MS = 16; + /** Exponential backoff (capped at 8s) between `/listen` reconnect attempts. */ function reconnectBackoffMs(attempt: number): number { return Math.min(500 * 2 ** (attempt - 1), 8_000); @@ -241,6 +251,30 @@ export class AgentChatService { const pump = async ( token: string | null, ): Promise<"remint" | "auth_failure" | "done"> => { + // Buffer mapped messages and write them at most once per frame. Flushed + // before every status transition (so messages always land before the + // status that follows them) and drained on every exit path. Safe to call + // repeatedly; drops the buffer if this pump has been superseded. + let pending: AcpMessage[] = []; + let flushTimer: ReturnType | null = null; + const flushPending = (): void => { + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + if (pending.length === 0) return; + const batch = pending; + pending = []; + if (rt.epoch !== epoch) return; + store.appendMessages(chatId, batch); + }; + const queueMessages = (messages: AcpMessage[]): void => { + if (messages.length === 0) return; + for (const message of messages) pending.push(message); + if (flushTimer === null) { + flushTimer = setTimeout(flushPending, AGENT_CHAT_FLUSH_MS); + } + }; try { for await (const event of client.streamAgentSession( session.ingressBaseUrl, @@ -248,19 +282,26 @@ export class AgentChatService { controller.signal, token, )) { - if (rt.epoch !== epoch) return "done"; + if (rt.epoch !== epoch) { + flushPending(); + return "done"; + } // Control event: don't surface to the user, just request a remint. - if (event.kind === "preview_token_required") return "remint"; + if (event.kind === "preview_token_required") { + flushPending(); + return "remint"; + } // Hard end (meta-end-session): the session is sealed and rejects // further `/send`s. Unlike `completed` (turn-end, stays open), this is // terminal — finalize and stop tailing. The mapper renders nothing for // it, so skip the append like the remint. if (event.kind === "closed") { + flushPending(); store.setStatus(chatId, "completed"); return "done"; } madeProgress = true; - store.appendMessages(chatId, rt.mapper.apply(event)); + queueMessages(rt.mapper.apply(event)); this.trackApprovalState(client, rt, session, chatId, epoch, event); if (event.kind === "client_tool_call") { void this.dispatchClientTool( @@ -271,10 +312,13 @@ export class AgentChatService { sessionId, ); } else if (event.kind === "completed") { + flushPending(); store.setStatus(chatId, "completed"); } else if (event.kind === "waiting") { + flushPending(); store.setStatus(chatId, "awaiting_input"); } else if (event.kind === "failed") { + flushPending(); store.setStatus(chatId, "failed"); store.setError( chatId, @@ -282,8 +326,11 @@ export class AgentChatService { ); } } + flushPending(); return "done"; } catch (err) { + // Surface whatever arrived before the drop, then let the loop decide. + flushPending(); if ( session.revisionId && !controller.signal.aborted && diff --git a/packages/core/src/terminal/identifiers.ts b/packages/core/src/terminal/identifiers.ts index 2924a4a774..02aff21656 100644 --- a/packages/core/src/terminal/identifiers.ts +++ b/packages/core/src/terminal/identifiers.ts @@ -10,4 +10,10 @@ export const SHELL_PROCESS_POLLER = Symbol.for( "posthog.core.terminal.shellProcessPoller", ); -export const SHELL_PROCESS_POLL_INTERVAL_MS = 500; +// Cadence for reading each open terminal's foreground process name (drives the +// tab label). This is one IPC round-trip per open shell panel, forever, so it +// keeps the renderer waking the main process even when nothing is happening. A +// 2s label lag is imperceptible for "vim"/"node"-style titles, and the poller +// only notifies on an actual name change, so a slower tick costs nothing in +// responsiveness while cutting the steady-state IPC/context-switch load ~4x. +export const SHELL_PROCESS_POLL_INTERVAL_MS = 2000; diff --git a/packages/ui/src/features/canvas/hooks/useNestedGenerationTaskIds.ts b/packages/ui/src/features/canvas/hooks/useNestedGenerationTaskIds.ts index 031ed34423..cc3f673be7 100644 --- a/packages/ui/src/features/canvas/hooks/useNestedGenerationTaskIds.ts +++ b/packages/ui/src/features/canvas/hooks/useNestedGenerationTaskIds.ts @@ -5,7 +5,7 @@ import { type TaskSession, } from "@posthog/core/sidebar/buildSidebarData"; import type { Task } from "@posthog/shared/domain-types"; -import { useSessions } from "@posthog/ui/features/sessions/useSession"; +import { useSidebarSessionMap } from "@posthog/ui/features/sidebar/useSidebarSessionMap"; import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; import { useMemo } from "react"; @@ -30,7 +30,10 @@ export function useNestedGenerationTaskIds( tasks: Task[] | undefined, openTaskId: string | undefined, ): ReadonlySet { - const sessions = useSessions(); + // Signature-guarded map: stable across the per-token event appends of a live + // turn, so this hook (mounted in the always-present channel tree) recomputes + // only when a nesting-relevant session field changes — not 60x/sec. + const sessionByTaskId = useSidebarSessionMap(); const { timestamps } = useTaskViewed(); return useMemo(() => { @@ -39,10 +42,6 @@ export function useNestedGenerationTaskIds( .filter((id): id is string => !!id); if (generationTaskIds.length === 0) return EMPTY_SET; - const sessionByTaskId = new Map(); - for (const session of Object.values(sessions)) { - if (session.taskId) sessionByTaskId.set(session.taskId, session); - } const taskById = new Map(tasks?.map((t) => [t.id, t]) ?? []); const nested = new Set(); @@ -70,5 +69,5 @@ export function useNestedGenerationTaskIds( nested.add(taskId); } return nested; - }, [dashboards, tasks, sessions, timestamps, openTaskId]); + }, [dashboards, tasks, sessionByTaskId, timestamps, openTaskId]); } diff --git a/packages/ui/src/features/git-interaction/useFixWithAgent.ts b/packages/ui/src/features/git-interaction/useFixWithAgent.ts index add89e3ef9..a636867303 100644 --- a/packages/ui/src/features/git-interaction/useFixWithAgent.ts +++ b/packages/ui/src/features/git-interaction/useFixWithAgent.ts @@ -2,7 +2,7 @@ import type { FixWithAgentPrompt } from "@posthog/core/git-interaction/errorProm import { useAppView } from "@posthog/ui/router/useAppView"; import { useCallback } from "react"; import { sendPromptToAgent } from "../sessions/sendPromptToAgent"; -import { useSessionForTask } from "../sessions/useSession"; +import { useSessionSelector } from "../sessions/useSession"; /** * Hook that sends a structured error prompt to the active agent session. @@ -18,8 +18,12 @@ export function useFixWithAgent( } { const view = useAppView(); const taskId = view.type === "task-detail" ? view.taskId : undefined; - const session = useSessionForTask(taskId); - const isSessionReady = session?.status === "connected"; + // Only the readiness flag is needed here — reading it as a primitive avoids + // re-rendering every consumer (diff views) on each streamed token. + const isSessionReady = useSessionSelector( + taskId, + (s) => s?.status === "connected", + ); const canFixWithAgent = !!(taskId && isSessionReady); diff --git a/packages/ui/src/features/inbox/hooks/useInboxReports.ts b/packages/ui/src/features/inbox/hooks/useInboxReports.ts index fb98e314c8..09dbf11379 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxReports.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxReports.ts @@ -117,7 +117,10 @@ export function useInboxAvailableSuggestedReviewers(options?: { staleTime: options?.staleTime ?? 5 * 60 * 1000, refetchOnMount: "always", refetchInterval: 60_000, - refetchIntervalInBackground: true, + // Don't keep polling while the app window is unfocused/backgrounded — the + // reviewer list isn't time-critical, and a refocus refetch + the 5min + // staleTime keep it fresh. Polling in the background just burns energy. + refetchIntervalInBackground: false, placeholderData: shouldUseCachedBaseList && cachedEntry ? { diff --git a/packages/ui/src/features/sessions/components/ModelSelector.tsx b/packages/ui/src/features/sessions/components/ModelSelector.tsx index 46a81da1c6..d2dc48864b 100644 --- a/packages/ui/src/features/sessions/components/ModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/ModelSelector.tsx @@ -19,7 +19,8 @@ import { stripGlmModelOption } from "@posthog/ui/features/sessions/modelOptionFi import { flattenSelectOptions, useModelConfigOptionForTask, - useSessionForTask, + useSessionIsCloud, + useSessionSelector, } from "@posthog/ui/features/sessions/sessionStore"; import { Fragment, useMemo } from "react"; @@ -36,7 +37,10 @@ export function ModelSelector({ onModelChange, }: ModelSelectorProps) { const sessionService = useService(SESSION_SERVICE); - const session = useSessionForTask(taskId); + // Narrow reads instead of the whole session, so the model dropdown doesn't + // re-render on every streamed token during a turn. + const sessionStatus = useSessionSelector(taskId, (s) => s?.status); + const sessionIsCloud = useSessionIsCloud(taskId); const rawModelOption = useModelConfigOptionForTask(taskId); const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); const modelOption = @@ -61,8 +65,8 @@ export function ModelSelector({ const handleChange = (value: string) => { onModelChange?.(value); - if (!taskId || !session) return; - if (session.status !== "connected" && !session.isCloud) return; + if (!taskId) return; + if (sessionStatus !== "connected" && !sessionIsCloud) return; sessionService.setSessionConfigOption(taskId, selectOption.id, value); }; diff --git a/packages/ui/src/features/sessions/components/QueuedMessagesDock.tsx b/packages/ui/src/features/sessions/components/QueuedMessagesDock.tsx index aac09fb972..bfa0567ccb 100644 --- a/packages/ui/src/features/sessions/components/QueuedMessagesDock.tsx +++ b/packages/ui/src/features/sessions/components/QueuedMessagesDock.tsx @@ -8,7 +8,8 @@ import { useSupportsNativeSteer } from "@posthog/ui/features/sessions/hooks/useM import { useReturnQueuedMessageToEditor } from "@posthog/ui/features/sessions/hooks/useReturnQueuedMessageToEditor"; import { sessionStoreSetters, - useSessionForTask, + useSessionIsCloud, + useSessionSelector, } from "@posthog/ui/features/sessions/sessionStore"; import { useQueuedMessagesForTask } from "@posthog/ui/features/sessions/useSession"; import { toast } from "@posthog/ui/primitives/toast"; @@ -28,12 +29,17 @@ export function QueuedMessagesDock({ taskId }: QueuedMessagesDockProps) { const sessionService = useService(SESSION_SERVICE); const supportsNativeSteer = useSupportsNativeSteer(taskId); const returnToEditor = useReturnQueuedMessageToEditor(taskId); - const session = useSessionForTask(taskId); + // Narrow reads (not the whole session) so the dock doesn't re-render on every + // streamed token while a turn is running. + const isCompacting = useSessionSelector( + taskId, + (s) => s?.isCompacting ?? false, + ); + const isCloud = useSessionIsCloud(taskId); // Steer can't inject mid-compaction, so it would be a silent no-op; hide it. // Cloud has no real mid-turn steer either (it would just interrupt the turn), // so hide it there too — the message stays queued and lands next turn. - const canSteer = - !(session?.isCompacting ?? false) && !(session?.isCloud ?? false); + const canSteer = !isCompacting && !isCloud; if (queued.length === 0) return null; diff --git a/packages/ui/src/features/sessions/sessionStore.ts b/packages/ui/src/features/sessions/sessionStore.ts index d266829b6f..2734a55854 100644 --- a/packages/ui/src/features/sessions/sessionStore.ts +++ b/packages/ui/src/features/sessions/sessionStore.ts @@ -88,6 +88,7 @@ export { useSessionForTask, useSessionHandoffInProgress, useSessionIsCloud, + useSessionSelector, useSessions, useThoughtLevelConfigOptionForTask, } from "./useSession"; diff --git a/packages/ui/src/features/sessions/useSession.ts b/packages/ui/src/features/sessions/useSession.ts index 00a9ccfaed..9b2e9bb9cc 100644 --- a/packages/ui/src/features/sessions/useSession.ts +++ b/packages/ui/src/features/sessions/useSession.ts @@ -30,6 +30,27 @@ export const useSessionForTask = ( return s.sessions[taskRunId]; }); +/** + * Select a derived value from a task's session with referential stability. + * + * Prefer this over {@link useSessionForTask} whenever a component needs only a + * field or two: `useSessionForTask` returns the whole session object, whose + * identity changes on every streamed event (the store appends to `events` via + * immer), so every consumer re-renders ~60fps for the length of a turn. This + * selector re-renders only when the projected value actually changes. Pass + * `shallow` as `equality` when the projection returns an object or array. + */ +export function useSessionSelector( + taskId: string | undefined, + select: (session: AgentSession | undefined) => T, + equality?: (a: T, b: T) => boolean, +): T { + return useSessionStore((s) => { + const taskRunId = taskId ? s.taskIdIndex[taskId] : undefined; + return select(taskRunId ? s.sessions[taskRunId] : undefined); + }, equality); +} + /** * Returns `null` when the agent hasn't sent an `available_commands_update` yet, * so callers can distinguish that from an explicit empty list. diff --git a/packages/ui/src/features/task-detail/components/TaskShellPanel.tsx b/packages/ui/src/features/task-detail/components/TaskShellPanel.tsx index dc9eb30c36..421683194c 100644 --- a/packages/ui/src/features/task-detail/components/TaskShellPanel.tsx +++ b/packages/ui/src/features/task-detail/components/TaskShellPanel.tsx @@ -2,7 +2,7 @@ import type { Task } from "@posthog/shared/domain-types"; import { Box } from "@radix-ui/themes"; import { useEffect } from "react"; import { usePanelLayoutStore } from "../../panels/panelLayoutStore"; -import { useSessionForTask } from "../../sessions/sessionStore"; +import { useSessionSelector } from "../../sessions/sessionStore"; import { ShellTerminal } from "../../terminal/ShellTerminal"; import { useTerminalStore } from "../../terminal/terminalStore"; import { useShellProcessPoller } from "../../terminal/useShellProcessPoller"; @@ -22,7 +22,9 @@ export function TaskShellPanel({ const stateKey = shellId ? `${taskId}-${shellId}` : taskId; const tabId = shellId || "shell"; - const session = useSessionForTask(taskId); + // Only the connection status gates rendering here; reading it narrowly keeps + // the terminal panel from re-rendering on every streamed token. + const sessionStatus = useSessionSelector(taskId, (s) => s?.status); const workspace = useWorkspace(taskId); const workspacePath = workspace?.worktreePath ?? workspace?.folderPath; @@ -39,7 +41,7 @@ export function TaskShellPanel({ } }, [processName, taskId, tabId, updateTabLabel]); - if (!workspacePath || !session || session.status === "connecting") { + if (!workspacePath || !sessionStatus || sessionStatus === "connecting") { return null; } diff --git a/packages/ui/src/features/terminal/TerminalManager.ts b/packages/ui/src/features/terminal/TerminalManager.ts index c6fd0cb66a..ca086a71c0 100644 --- a/packages/ui/src/features/terminal/TerminalManager.ts +++ b/packages/ui/src/features/terminal/TerminalManager.ts @@ -15,6 +15,24 @@ import { Terminal as XTerm } from "@xterm/xterm"; const log = logger.scope("terminal-manager"); +// A terminal on a hidden tab stays mounted (TabbedPanel toggles visibility, it +// doesn't unmount), so it keeps receiving pty output and rendering to an +// off-screen canvas. There's no point repainting an invisible terminal at +// 60fps, so its writes coalesce onto this slower timer instead of an animation +// frame — the buffered output is still there in full the moment the tab shows. +const HIDDEN_TERMINAL_FLUSH_MS = 250; + +// Whether an attached terminal element is actually on-screen. `checkVisibility` +// with `visibilityProperty` catches the `visibility: hidden` a background tab +// uses (plain `checkVisibility()` ignores it). Absent/older engines fall back to +// "visible", preserving the animation-frame path. +function isElementVisible(element: HTMLElement | null): boolean { + if (!element) return true; + const check = element.checkVisibility?.bind(element); + if (!check) return true; + return check({ visibilityProperty: true }); +} + let parkingContainer: HTMLElement | null = null; function getParkingContainer(): HTMLElement { @@ -38,7 +56,8 @@ export interface TerminalInstance { serializeAddon: SerializeAddon; webglAddon: WebglAddon | null; writeBuffer: string; - flushHandle: number | null; + /** Cancels the pending write flush, whichever timer scheduled it. */ + flushCancel: (() => void) | null; attachedElement: HTMLElement | null; terminalElement: HTMLElement | null; isReady: boolean; @@ -204,7 +223,7 @@ class TerminalManagerImpl { serializeAddon: serialize, webglAddon: null, writeBuffer: "", - flushHandle: null, + flushCancel: null, attachedElement: null, terminalElement: null, isReady: false, @@ -312,20 +331,30 @@ class TerminalManagerImpl { // Coalesce bursts of pty output into a single term.write() per animation // frame instead of one call per IPC chunk, cutting the per-call parse and // write-buffer overhead that piles up on the main thread under a heavy - // stream (build logs, cat-ing a file). + // stream (build logs, cat-ing a file). A terminal that isn't currently + // visible (background tab) coalesces onto a slower timer instead, so it + // stops repainting an off-screen canvas 60 times a second. instance.writeBuffer += data; - if (instance.flushHandle === null) { - instance.flushHandle = requestAnimationFrame(() => { - instance.flushHandle = null; - this.flushWrite(sessionId, instance); - }); + if (instance.flushCancel !== null) { + return; + } + const flush = () => { + instance.flushCancel = null; + this.flushWrite(sessionId, instance); + }; + if (isElementVisible(instance.attachedElement)) { + const handle = requestAnimationFrame(flush); + instance.flushCancel = () => cancelAnimationFrame(handle); + } else { + const handle = window.setTimeout(flush, HIDDEN_TERMINAL_FLUSH_MS); + instance.flushCancel = () => window.clearTimeout(handle); } } private flushWrite(sessionId: string, instance: TerminalInstance): void { - if (instance.flushHandle !== null) { - cancelAnimationFrame(instance.flushHandle); - instance.flushHandle = null; + if (instance.flushCancel !== null) { + instance.flushCancel(); + instance.flushCancel = null; } if (instance.writeBuffer.length === 0) { return; @@ -551,9 +580,9 @@ class TerminalManagerImpl { clearTimeout(instance.saveTimeout); } - if (instance.flushHandle !== null) { - cancelAnimationFrame(instance.flushHandle); - instance.flushHandle = null; + if (instance.flushCancel !== null) { + instance.flushCancel(); + instance.flushCancel = null; } instance.webglAddon?.dispose(); diff --git a/packages/ui/src/shell/GlobalEventHandlers.tsx b/packages/ui/src/shell/GlobalEventHandlers.tsx index 4664c87964..39f24b0145 100644 --- a/packages/ui/src/shell/GlobalEventHandlers.tsx +++ b/packages/ui/src/shell/GlobalEventHandlers.tsx @@ -27,6 +27,7 @@ import { useAppView } from "@posthog/ui/router/useAppView"; import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; import { logger } from "@posthog/ui/shell/logger"; +import { useRendererWindowFocusStore } from "@posthog/ui/shell/rendererWindowFocusStore"; import { clearApplicationStorage } from "@posthog/ui/utils/clearStorage"; import { useSubscription } from "@trpc/tanstack-react-query"; import { useCallback, useEffect, useMemo, useRef } from "react"; @@ -269,6 +270,15 @@ export function GlobalEventHandlers({ return () => window.removeEventListener("focus", handleFocus); }, [loadFolders, sessionService]); + // Freeze perpetual CSS animations while the window is backgrounded (see the + // `.ph-window-blurred` rule in globals.css). Driven by the shared focus store + // so we don't add yet another blur/focus listener. + const windowFocused = useRendererWindowFocusStore((s) => s.focused); + useEffect(() => { + document.body.classList.toggle("ph-window-blurred", !windowFocused); + return () => document.body.classList.remove("ph-window-blurred"); + }, [windowFocused]); + // Check if current task's folder became invalid (e.g., moved while app was open) useEffect(() => { if (view.type !== "task-detail" || !view.taskId) return; diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index abc701a68d..4712bec6bd 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -398,6 +398,18 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { animation: ph-pulse 1s ease-in-out infinite; } +/* Freeze CSS keyframe animations while the app window is unfocused or hidden. + Perpetual indicators (spinners, pulses, floats) otherwise keep the compositor + and GPU busy in the background for animations nobody is looking at — and some, + like ph-pulse's color shift, force main-thread repaints every frame. The class + is toggled from the renderer focus store (see GlobalEventHandlers). Transitions + are unaffected, and animations resume where they left off on refocus. */ +body.ph-window-blurred *, +body.ph-window-blurred *::before, +body.ph-window-blurred *::after { + animation-play-state: paused !important; +} + .radix-themes { --cursor-button: pointer; --cursor-checkbox: pointer;