From 94e8cb7507cb0c937e4d31467694ac5dcac33a01 Mon Sep 17 00:00:00 2001 From: Damian P Date: Tue, 10 Feb 2026 11:09:41 +0100 Subject: [PATCH 1/2] fix: resolve high CPU usage from event subscription loop and aggressive polling - useAppServerEvents: stabilize subscription with useRef to prevent infinite subscribe/unsubscribe loop caused by handlers object recreation on every render - useWorkspaceFiles: reduce polling frequency (30s/60s instead of 5s/20s) and pause polling when tab is hidden Fixes #393 Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019c4703-2ce1-7308-9585-7f7c41730fbb --- src/features/app/hooks/useAppServerEvents.ts | 67 +++++++++++-------- .../hooks/useRemoteThreadRefreshOnFocus.ts | 31 ++++++--- src/features/debug/hooks/useDebugLog.ts | 43 +++++++----- .../workspaces/hooks/useWorkspaceFiles.ts | 8 +++ .../hooks/useWorkspaceRefreshOnFocus.ts | 52 +++++++++----- 5 files changed, 131 insertions(+), 70 deletions(-) diff --git a/src/features/app/hooks/useAppServerEvents.ts b/src/features/app/hooks/useAppServerEvents.ts index 137f5f49b..7d3c8a8c3 100644 --- a/src/features/app/hooks/useAppServerEvents.ts +++ b/src/features/app/hooks/useAppServerEvents.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import type { AppServerEvent, ApprovalRequest, @@ -118,9 +118,18 @@ export const METHODS_ROUTED_IN_USE_APP_SERVER_EVENTS = [ ] as const satisfies readonly SupportedAppServerMethod[]; export function useAppServerEvents(handlers: AppServerEventHandlers) { + // Use ref to keep handlers current without triggering re-subscription + const handlersRef = useRef(handlers); + + // Update ref on every render to always have latest handlers + useEffect(() => { + handlersRef.current = handlers; + }); + useEffect(() => { const unlisten = subscribeAppServerEvents((payload) => { - handlers.onAppServerEvent?.(payload); + const currentHandlers = handlersRef.current; + currentHandlers.onAppServerEvent?.(payload); const { workspace_id } = payload; const method = getAppServerRawMethod(payload); @@ -130,7 +139,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const params = getAppServerParams(payload); if (method === "codex/connected") { - handlers.onWorkspaceConnected?.(workspace_id); + currentHandlers.onWorkspaceConnected?.(workspace_id); return; } @@ -138,7 +147,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const hasRequestId = requestId !== null; if (isApprovalRequestMethod(method) && hasRequestId) { - handlers.onApprovalRequest?.({ + currentHandlers.onApprovalRequest?.({ workspace_id, request_id: requestId as string | number, method, @@ -177,7 +186,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { }; }) .filter((question) => question.id); - handlers.onRequestUserInput?.({ + currentHandlers.onRequestUserInput?.({ workspace_id, request_id: requestId as string | number, params: { @@ -195,7 +204,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const delta = String(params.delta ?? ""); if (threadId && itemId && delta) { - handlers.onAgentMessageDelta?.({ + currentHandlers.onAgentMessageDelta?.({ workspaceId: workspace_id, threadId, itemId, @@ -212,7 +221,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { ); const turnId = String(turn?.id ?? params.turnId ?? params.turn_id ?? ""); if (threadId) { - handlers.onTurnStarted?.(workspace_id, threadId, turnId); + currentHandlers.onTurnStarted?.(workspace_id, threadId, turnId); } return; } @@ -221,7 +230,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const thread = (params.thread as Record | undefined) ?? null; const threadId = String(thread?.id ?? ""); if (thread && threadId) { - handlers.onThreadStarted?.(workspace_id, thread); + currentHandlers.onThreadStarted?.(workspace_id, thread); } return; } @@ -234,7 +243,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { ? threadNameRaw.trim() : null; if (threadId) { - handlers.onThreadNameUpdated?.(workspace_id, { threadId, threadName }); + currentHandlers.onThreadNameUpdated?.(workspace_id, { threadId, threadName }); } return; } @@ -243,7 +252,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const threadId = String(params.threadId ?? params.thread_id ?? ""); const action = String(params.action ?? "hide"); if (threadId) { - handlers.onBackgroundThreadAction?.(workspace_id, threadId, action); + currentHandlers.onBackgroundThreadAction?.(workspace_id, threadId, action); } return; } @@ -255,7 +264,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const messageText = String(error.message ?? ""); const willRetry = Boolean(params.willRetry ?? params.will_retry); if (threadId) { - handlers.onTurnError?.(workspace_id, threadId, turnId, { + currentHandlers.onTurnError?.(workspace_id, threadId, turnId, { message: messageText, willRetry, }); @@ -270,7 +279,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { ); const turnId = String(turn?.id ?? params.turnId ?? params.turn_id ?? ""); if (threadId) { - handlers.onTurnCompleted?.(workspace_id, threadId, turnId); + currentHandlers.onTurnCompleted?.(workspace_id, threadId, turnId); } return; } @@ -279,7 +288,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const threadId = String(params.threadId ?? params.thread_id ?? ""); const turnId = String(params.turnId ?? params.turn_id ?? ""); if (threadId) { - handlers.onTurnPlanUpdated?.(workspace_id, threadId, turnId, { + currentHandlers.onTurnPlanUpdated?.(workspace_id, threadId, turnId, { explanation: params.explanation, plan: params.plan, }); @@ -291,7 +300,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const threadId = String(params.threadId ?? params.thread_id ?? ""); const diff = String(params.diff ?? ""); if (threadId && diff) { - handlers.onTurnDiffUpdated?.(workspace_id, threadId, diff); + currentHandlers.onTurnDiffUpdated?.(workspace_id, threadId, diff); } return; } @@ -302,7 +311,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { (params.tokenUsage as Record | null | undefined) ?? (params.token_usage as Record | null | undefined); if (threadId && tokenUsage !== undefined) { - handlers.onThreadTokenUsageUpdated?.(workspace_id, threadId, tokenUsage); + currentHandlers.onThreadTokenUsageUpdated?.(workspace_id, threadId, tokenUsage); } return; } @@ -312,7 +321,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { (params.rateLimits as Record | undefined) ?? (params.rate_limits as Record | undefined); if (rateLimits) { - handlers.onAccountRateLimitsUpdated?.(workspace_id, rateLimits); + currentHandlers.onAccountRateLimitsUpdated?.(workspace_id, rateLimits); } return; } @@ -323,7 +332,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { typeof authModeRaw === "string" && authModeRaw.trim().length > 0 ? authModeRaw : null; - handlers.onAccountUpdated?.(workspace_id, authMode); + currentHandlers.onAccountUpdated?.(workspace_id, authMode); return; } @@ -337,7 +346,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const errorRaw = params.error ?? null; const error = typeof errorRaw === "string" && errorRaw.trim().length > 0 ? errorRaw : null; - handlers.onAccountLoginCompleted?.(workspace_id, { + currentHandlers.onAccountLoginCompleted?.(workspace_id, { loginId, success, error, @@ -349,13 +358,13 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const threadId = String(params.threadId ?? params.thread_id ?? ""); const item = params.item as Record | undefined; if (threadId && item) { - handlers.onItemCompleted?.(workspace_id, threadId, item); + currentHandlers.onItemCompleted?.(workspace_id, threadId, item); } if (threadId && item?.type === "agentMessage") { const itemId = String(item.id ?? ""); const text = String(item.text ?? ""); if (itemId) { - handlers.onAgentMessageCompleted?.({ + currentHandlers.onAgentMessageCompleted?.({ workspaceId: workspace_id, threadId, itemId, @@ -370,7 +379,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const threadId = String(params.threadId ?? params.thread_id ?? ""); const item = params.item as Record | undefined; if (threadId && item) { - handlers.onItemStarted?.(workspace_id, threadId, item); + currentHandlers.onItemStarted?.(workspace_id, threadId, item); } return; } @@ -380,7 +389,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const delta = String(params.delta ?? ""); if (threadId && itemId && delta) { - handlers.onReasoningSummaryDelta?.(workspace_id, threadId, itemId, delta); + currentHandlers.onReasoningSummaryDelta?.(workspace_id, threadId, itemId, delta); } return; } @@ -389,7 +398,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const threadId = String(params.threadId ?? params.thread_id ?? ""); const itemId = String(params.itemId ?? params.item_id ?? ""); if (threadId && itemId) { - handlers.onReasoningSummaryBoundary?.(workspace_id, threadId, itemId); + currentHandlers.onReasoningSummaryBoundary?.(workspace_id, threadId, itemId); } return; } @@ -399,7 +408,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const delta = String(params.delta ?? ""); if (threadId && itemId && delta) { - handlers.onReasoningTextDelta?.(workspace_id, threadId, itemId, delta); + currentHandlers.onReasoningTextDelta?.(workspace_id, threadId, itemId, delta); } return; } @@ -409,7 +418,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const delta = String(params.delta ?? ""); if (threadId && itemId && delta) { - handlers.onPlanDelta?.(workspace_id, threadId, itemId, delta); + currentHandlers.onPlanDelta?.(workspace_id, threadId, itemId, delta); } return; } @@ -419,7 +428,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const delta = String(params.delta ?? ""); if (threadId && itemId && delta) { - handlers.onCommandOutputDelta?.(workspace_id, threadId, itemId, delta); + currentHandlers.onCommandOutputDelta?.(workspace_id, threadId, itemId, delta); } return; } @@ -429,7 +438,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const stdin = String(params.stdin ?? ""); if (threadId && itemId) { - handlers.onTerminalInteraction?.(workspace_id, threadId, itemId, stdin); + currentHandlers.onTerminalInteraction?.(workspace_id, threadId, itemId, stdin); } return; } @@ -439,7 +448,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const delta = String(params.delta ?? ""); if (threadId && itemId && delta) { - handlers.onFileChangeOutputDelta?.(workspace_id, threadId, itemId, delta); + currentHandlers.onFileChangeOutputDelta?.(workspace_id, threadId, itemId, delta); } return; } @@ -448,5 +457,5 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { return () => { unlisten(); }; - }, [handlers]); + }, []); } diff --git a/src/features/app/hooks/useRemoteThreadRefreshOnFocus.ts b/src/features/app/hooks/useRemoteThreadRefreshOnFocus.ts index 6dd0211da..4985cc155 100644 --- a/src/features/app/hooks/useRemoteThreadRefreshOnFocus.ts +++ b/src/features/app/hooks/useRemoteThreadRefreshOnFocus.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import type { WorkspaceInfo } from "../../../types"; type UseRemoteThreadRefreshOnFocusOptions = { @@ -14,16 +14,28 @@ export function useRemoteThreadRefreshOnFocus({ activeThreadId, refreshThread, }: UseRemoteThreadRefreshOnFocusOptions) { + const optionsRef = useRef({ backendMode, activeWorkspace, activeThreadId, refreshThread }); useEffect(() => { - if (backendMode !== "remote") { - return; - } + optionsRef.current = { backendMode, activeWorkspace, activeThreadId, refreshThread }; + }); + + useEffect(() => { + let debounceTimer: ReturnType | null = null; const refreshActiveThread = () => { - if (!activeWorkspace?.connected || !activeThreadId) { - return; + if (debounceTimer) { + clearTimeout(debounceTimer); } - void refreshThread(activeWorkspace.id, activeThreadId); + debounceTimer = setTimeout(() => { + const { backendMode: mode, activeWorkspace: ws, activeThreadId: threadId, refreshThread: refresh } = optionsRef.current; + if (mode !== "remote") { + return; + } + if (!ws?.connected || !threadId) { + return; + } + void refresh(ws.id, threadId); + }, 500); }; const handleVisibilityChange = () => { @@ -37,6 +49,9 @@ export function useRemoteThreadRefreshOnFocus({ return () => { window.removeEventListener("focus", refreshActiveThread); document.removeEventListener("visibilitychange", handleVisibilityChange); + if (debounceTimer) { + clearTimeout(debounceTimer); + } }; - }, [activeThreadId, activeWorkspace, backendMode, refreshThread]); + }, []); } diff --git a/src/features/debug/hooks/useDebugLog.ts b/src/features/debug/hooks/useDebugLog.ts index f6bc7065e..9d2ce10a9 100644 --- a/src/features/debug/hooks/useDebugLog.ts +++ b/src/features/debug/hooks/useDebugLog.ts @@ -1,13 +1,34 @@ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import type { DebugEntry } from "../../../types"; const MAX_DEBUG_ENTRIES = 200; +function summarizePayload(payload: unknown): unknown { + if (Array.isArray(payload)) { + return { _type: "array", count: payload.length, sample: payload.slice(0, 5) }; + } + if (payload && typeof payload === "object") { + const obj = payload as Record; + const summarized: Record = {}; + for (const key of Object.keys(obj)) { + if (Array.isArray(obj[key])) { + summarized[key] = { _type: "array", count: (obj[key] as unknown[]).length }; + } else { + summarized[key] = obj[key]; + } + } + return summarized; + } + return payload; +} + export function useDebugLog() { const [debugOpen, setDebugOpenState] = useState(false); const [debugEntries, setDebugEntries] = useState([]); const [hasDebugAlerts, setHasDebugAlerts] = useState(false); const [debugPinned, setDebugPinned] = useState(false); + const debugOpenRef = useRef(debugOpen); + debugOpenRef.current = debugOpen; const isAlertEntry = useCallback((entry: DebugEntry) => { if (entry.source === "error" || entry.source === "stderr") { @@ -24,27 +45,19 @@ export function useDebugLog() { return false; }, []); - const shouldStoreEntry = useCallback( - (entry: DebugEntry) => { - if (debugOpen) { - return true; - } - return isAlertEntry(entry); - }, - [debugOpen, isAlertEntry], - ); - const addDebugEntry = useCallback( (entry: DebugEntry) => { - if (!shouldStoreEntry(entry)) { + const isAlert = isAlertEntry(entry); + if (!debugOpenRef.current && !isAlert) { return; } - if (isAlertEntry(entry)) { + if (isAlert) { setHasDebugAlerts(true); } - setDebugEntries((prev) => [...prev, entry].slice(-MAX_DEBUG_ENTRIES)); + const compactEntry = { ...entry, payload: summarizePayload(entry.payload) }; + setDebugEntries((prev) => [...prev, compactEntry].slice(-MAX_DEBUG_ENTRIES)); }, - [isAlertEntry, shouldStoreEntry], + [isAlertEntry], ); const handleCopyDebug = useCallback(async () => { diff --git a/src/features/workspaces/hooks/useWorkspaceFiles.ts b/src/features/workspaces/hooks/useWorkspaceFiles.ts index c53d539e0..c4a8ff6bc 100644 --- a/src/features/workspaces/hooks/useWorkspaceFiles.ts +++ b/src/features/workspaces/hooks/useWorkspaceFiles.ts @@ -114,10 +114,18 @@ export function useWorkspaceFiles({ if (!workspaceId || !isConnected || !isPollingEnabled) { return; } + // Pause polling when tab is hidden + if (document.visibilityState === "hidden") { + return; + } const refreshInterval = files.length > LARGE_FILE_COUNT ? LARGE_REFRESH_INTERVAL_MS : REFRESH_INTERVAL_MS; const interval = window.setInterval(() => { + // Skip if tab is hidden + if (document.visibilityState === "hidden") { + return; + } refreshFiles().catch(() => {}); }, refreshInterval); diff --git a/src/features/workspaces/hooks/useWorkspaceRefreshOnFocus.ts b/src/features/workspaces/hooks/useWorkspaceRefreshOnFocus.ts index df14caaf1..b7fc03c1a 100644 --- a/src/features/workspaces/hooks/useWorkspaceRefreshOnFocus.ts +++ b/src/features/workspaces/hooks/useWorkspaceRefreshOnFocus.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import type { WorkspaceInfo } from "../../../types"; type WorkspaceRefreshOptions = { @@ -15,25 +15,38 @@ export function useWorkspaceRefreshOnFocus({ refreshWorkspaces, listThreadsForWorkspace, }: WorkspaceRefreshOptions) { + const optionsRef = useRef({ workspaces, refreshWorkspaces, listThreadsForWorkspace }); useEffect(() => { + optionsRef.current = { workspaces, refreshWorkspaces, listThreadsForWorkspace }; + }); + + useEffect(() => { + let debounceTimer: ReturnType | null = null; + const handleFocus = () => { - void (async () => { - let latestWorkspaces = workspaces; - try { - const entries = await refreshWorkspaces(); - if (entries) { - latestWorkspaces = entries; + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + const { workspaces: ws, refreshWorkspaces: refresh, listThreadsForWorkspace: listThreads } = optionsRef.current; + void (async () => { + let latestWorkspaces = ws; + try { + const entries = await refresh(); + if (entries) { + latestWorkspaces = entries; + } + } catch { + // Silent: refresh errors show in debug panel. } - } catch { - // Silent: refresh errors show in debug panel. - } - const connected = latestWorkspaces.filter((entry) => entry.connected); - await Promise.allSettled( - connected.map((workspace) => - listThreadsForWorkspace(workspace, { preserveState: true }), - ), - ); - })(); + const connected = latestWorkspaces.filter((entry) => entry.connected); + await Promise.allSettled( + connected.map((workspace) => + listThreads(workspace, { preserveState: true }), + ), + ); + })(); + }, 500); }; const handleVisibilityChange = () => { @@ -47,6 +60,9 @@ export function useWorkspaceRefreshOnFocus({ return () => { window.removeEventListener("focus", handleFocus); document.removeEventListener("visibilitychange", handleVisibilityChange); + if (debounceTimer) { + clearTimeout(debounceTimer); + } }; - }, [listThreadsForWorkspace, refreshWorkspaces, workspaces]); + }, []); } From 2d554824eb13263796043926fb22e7d9a8015d1a Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Tue, 10 Feb 2026 19:14:51 +0100 Subject: [PATCH 2/2] perf(workspaces): reduce file polling and pause when hidden --- .../workspaces/hooks/useWorkspaceFiles.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/features/workspaces/hooks/useWorkspaceFiles.ts b/src/features/workspaces/hooks/useWorkspaceFiles.ts index c4a8ff6bc..bb68c86ae 100644 --- a/src/features/workspaces/hooks/useWorkspaceFiles.ts +++ b/src/features/workspaces/hooks/useWorkspaceFiles.ts @@ -32,11 +32,14 @@ export function useWorkspaceFiles({ }: UseWorkspaceFilesOptions) { const [files, setFiles] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [isDocumentVisible, setIsDocumentVisible] = useState( + () => document.visibilityState !== "hidden", + ); const lastFetchedWorkspaceId = useRef(null); const inFlight = useRef(null); - const REFRESH_INTERVAL_MS = 5000; - const LARGE_REFRESH_INTERVAL_MS = 20000; + const REFRESH_INTERVAL_MS = 30000; + const LARGE_REFRESH_INTERVAL_MS = 60000; const LARGE_FILE_COUNT = 20000; const workspaceId = activeWorkspace?.id ?? null; const isConnected = Boolean(activeWorkspace?.connected); @@ -100,6 +103,16 @@ export function useWorkspaceFiles({ setIsLoading(Boolean(workspaceId && isConnected && isEnabled)); }, [isConnected, isEnabled, workspaceId]); + useEffect(() => { + const handleVisibilityChange = () => { + setIsDocumentVisible(document.visibilityState !== "hidden"); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, []); + useEffect(() => { if (!workspaceId || !isConnected || !isEnabled) { return; @@ -111,11 +124,7 @@ export function useWorkspaceFiles({ }, [files.length, isConnected, isEnabled, refreshFiles, workspaceId]); useEffect(() => { - if (!workspaceId || !isConnected || !isPollingEnabled) { - return; - } - // Pause polling when tab is hidden - if (document.visibilityState === "hidden") { + if (!workspaceId || !isConnected || !isPollingEnabled || !isDocumentVisible) { return; } const refreshInterval = @@ -132,7 +141,7 @@ export function useWorkspaceFiles({ return () => { window.clearInterval(interval); }; - }, [files.length, isConnected, isPollingEnabled, refreshFiles, workspaceId]); + }, [files.length, isConnected, isDocumentVisible, isPollingEnabled, refreshFiles, workspaceId]); const fileOptions = useMemo(() => files.filter(Boolean), [files]);