diff --git a/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts b/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts index 4b3cf7656d3..a559c2d38e0 100644 --- a/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts +++ b/apps/desktop/src/main/lib/terminal-host/headless-emulator.ts @@ -10,7 +10,10 @@ import "../../terminal-host/xterm-env-polyfill"; import { SerializeAddon } from "@xterm/addon-serialize"; import { Terminal } from "@xterm/headless"; -import { DEFAULT_TERMINAL_SCROLLBACK } from "shared/constants"; +import { + DEFAULT_TERMINAL_SCROLLBACK, + MAX_TERMINAL_SCROLLBACK, +} from "shared/constants"; import { DEFAULT_MODES, type TerminalModes, @@ -86,7 +89,10 @@ export class HeadlessEmulator { this.terminal = new Terminal({ cols, rows, - scrollback, + scrollback: Math.min( + Math.max(0, Math.floor(Number.isFinite(Number(scrollback)) ? Number(scrollback) : 0)), + MAX_TERMINAL_SCROLLBACK, + ), allowProposedApi: true, }); diff --git a/apps/desktop/src/main/terminal-host/terminal-host-rss-sweep.test.ts b/apps/desktop/src/main/terminal-host/terminal-host-rss-sweep.test.ts new file mode 100644 index 00000000000..fcf4a0e108f --- /dev/null +++ b/apps/desktop/src/main/terminal-host/terminal-host-rss-sweep.test.ts @@ -0,0 +1,240 @@ +/** + * Tests for TerminalHost RSS sweep (Task 3.3 — Phase 3 memory optimizations) + * + * The sweep runs every 5 minutes and kills any terminal session whose + * process-tree RSS exceeds 512 MB. + */ + +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; + +// --------------------------------------------------------------------------- +// Inner mock functions — reset per-test, delegated from spies set in beforeAll +// --------------------------------------------------------------------------- + +const mockCaptureProcessSnapshot = mock(async () => ({ + byPid: new Map< + number, + { pid: number; ppid: number; cpu: number; memory: number } + >(), + childrenOf: new Map(), +})); + +const mockGetSubtreeResources = mock( + ( + _snap: unknown, + _pid: number, + ): { cpu: number; memory: number; pids: number[] } => ({ + cpu: 0, + memory: 0, + pids: [], + }), +); + +// --------------------------------------------------------------------------- +// Lazy TerminalHost — imported after spies are in place +// --------------------------------------------------------------------------- + +let TerminalHost: typeof import("./terminal-host").TerminalHost; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const MB = 1024 * 1024; +const LIMIT_BYTES = 512 * MB; + +function makeFakeSession( + sessionId: string, + pid: number | null, + isAttachable = true, +) { + return { + sessionId, + pid, + isAttachable, + isTerminating: false, + isAlive: true, + clientCount: 0, + kill: mock(() => {}), + dispose: mock(async () => {}), + }; +} + +// --------------------------------------------------------------------------- +// Private-member access helper (avoids repeated casts in each test) +// --------------------------------------------------------------------------- + +type FakeSession = ReturnType; + +type HostInternal = { + stopIdleSweep: () => void; + stopRssSweep: () => void; + kill: ReturnType; + sessions: Map; + runRssSweep: () => Promise; +}; + +function internal(h: InstanceType): HostInternal { + return h as unknown as HostInternal; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("TerminalHost.runRssSweep", () => { + let host: InstanceType; + + beforeAll(async () => { + // Set up spies on the module namespace BEFORE TerminalHost is imported. + // Bun's ES live bindings ensure TerminalHost sees the spy implementations. + const processTree = await import("../lib/resource-metrics/process-tree"); + spyOn(processTree, "captureProcessSnapshot").mockImplementation((...args) => + mockCaptureProcessSnapshot(...args), + ); + spyOn(processTree, "getSubtreeResources").mockImplementation((...args) => + mockGetSubtreeResources(...args), + ); + ({ TerminalHost } = await import("./terminal-host")); + }); + + afterAll(() => { + mock.restore(); + }); + + beforeEach(() => { + // Reset call history AND implementations so tests are fully isolated. + mockCaptureProcessSnapshot.mockReset(); + mockGetSubtreeResources.mockReset(); + + // Re-establish default (no-op) implementations after reset. + mockCaptureProcessSnapshot.mockImplementation(async () => ({ + byPid: new Map< + number, + { pid: number; ppid: number; cpu: number; memory: number } + >(), + childrenOf: new Map(), + })); + mockGetSubtreeResources.mockImplementation(() => ({ + cpu: 0, + memory: 0, + pids: [] as number[], + })); + + host = new TerminalHost(); + // Stop background timers immediately — we drive sweep manually. + internal(host).stopIdleSweep(); + internal(host).stopRssSweep(); + // Stub out `kill` so fake sessions don't trigger kill-timers. + internal(host).kill = mock(() => ({ success: true })); + }); + + afterEach(async () => { + await host.dispose(); + }); + + it("skips captureProcessSnapshot when no session has a PID", async () => { + const s = makeFakeSession("s-nopid", null); + internal(host).sessions.set("s-nopid", s); + + await internal(host).runRssSweep(); + + expect(mockCaptureProcessSnapshot).not.toHaveBeenCalled(); + }); + + it("silently swallows captureProcessSnapshot failures", async () => { + mockCaptureProcessSnapshot.mockImplementation(() => + Promise.reject(new Error("ps unavailable")), + ); + + const s = makeFakeSession("s-psfail", 1001); + internal(host).sessions.set("s-psfail", s); + + // Must resolve, not throw. + await expect(internal(host).runRssSweep()).resolves.toBeUndefined(); + expect(internal(host).kill).not.toHaveBeenCalled(); + }); + + it("kills a session whose RSS exceeds 512 MB", async () => { + mockGetSubtreeResources.mockImplementation(() => ({ + cpu: 20, + memory: LIMIT_BYTES + 1, + pids: [1002], + })); + + const s = makeFakeSession("s-heavy", 1002); + internal(host).sessions.set("s-heavy", s); + + await internal(host).runRssSweep(); + + expect(internal(host).kill).toHaveBeenCalledWith({ + sessionId: "s-heavy", + deleteHistory: false, + }); + }); + + it("does not kill a session whose RSS is at or below 512 MB", async () => { + mockGetSubtreeResources.mockImplementation(() => ({ + cpu: 5, + memory: LIMIT_BYTES, + pids: [1003], + })); + + const s = makeFakeSession("s-ok", 1003); + internal(host).sessions.set("s-ok", s); + + await internal(host).runRssSweep(); + + expect(internal(host).kill).not.toHaveBeenCalled(); + }); + + it("skips sessions that are not attachable", async () => { + mockGetSubtreeResources.mockImplementation(() => ({ + cpu: 99, + memory: LIMIT_BYTES * 2, + pids: [1004], + })); + + // isAttachable = false → should be filtered before captureProcessSnapshot + const s = makeFakeSession("s-dead", 1004, false); + internal(host).sessions.set("s-dead", s); + + await internal(host).runRssSweep(); + + expect(mockCaptureProcessSnapshot).not.toHaveBeenCalled(); + expect(internal(host).kill).not.toHaveBeenCalled(); + }); + + it("only kills over-limit sessions when mixed with healthy ones", async () => { + mockGetSubtreeResources.mockImplementation( + (_snap: unknown, pid: number) => ({ + cpu: 0, + memory: pid === 2001 ? LIMIT_BYTES + 1 : 100 * MB, + pids: [pid], + }), + ); + + const heavy = makeFakeSession("s-heavy2", 2001); + const light = makeFakeSession("s-light", 2002); + internal(host).sessions.set("s-heavy2", heavy); + internal(host).sessions.set("s-light", light); + + await internal(host).runRssSweep(); + + expect(internal(host).kill).toHaveBeenCalledTimes(1); + expect(internal(host).kill).toHaveBeenCalledWith({ + sessionId: "s-heavy2", + deleteHistory: false, + }); + }); +}); diff --git a/apps/desktop/src/main/terminal-host/terminal-host.ts b/apps/desktop/src/main/terminal-host/terminal-host.ts index 82cea86b593..fa820eb5a6b 100644 --- a/apps/desktop/src/main/terminal-host/terminal-host.ts +++ b/apps/desktop/src/main/terminal-host/terminal-host.ts @@ -9,6 +9,11 @@ */ import type { Socket } from "node:net"; +import { + captureProcessSnapshot, + getSubtreeResources, + type ProcessSnapshot, +} from "../lib/resource-metrics/process-tree"; import { TerminalAttachCanceledError } from "../lib/terminal/errors"; import type { CancelCreateOrAttachRequest, @@ -35,6 +40,14 @@ const KILL_TIMEOUT_MS = 5000; const MAX_CONCURRENT_SPAWNS = 3; const SPAWN_READY_TIMEOUT_MS = 5000; +/** Auto-kill idle sessions with no attached clients after this duration */ +const IDLE_SESSION_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour +const IDLE_SWEEP_INTERVAL_MS = 10 * 60 * 1000; // sweep every 10 minutes + +/** Kill sessions whose process tree RSS exceeds this threshold */ +const MAX_SESSION_RSS_BYTES = 512 * 1024 * 1024; // 512 MB +const RSS_SWEEP_INTERVAL_MS = 5 * 60 * 1000; // check every 5 minutes + interface PendingAttach { requestId: string; abortController: AbortController; @@ -72,6 +85,8 @@ export class TerminalHost { private killTimers: Map = new Map(); private pendingAttaches: Map = new Map(); private spawnLimiter = new Semaphore(MAX_CONCURRENT_SPAWNS); + private idleSweepTimer: NodeJS.Timeout | null = null; + private rssSweepTimer: NodeJS.Timeout | null = null; private onUnattachedExit?: (event: { sessionId: string; exitCode: number; @@ -88,6 +103,73 @@ export class TerminalHost { }) => void; } = {}) { this.onUnattachedExit = onUnattachedExit; + this.startIdleSweep(); + this.startRssSweep(); + } + + private startIdleSweep(): void { + this.idleSweepTimer = setInterval(() => { + const now = Date.now(); + for (const session of this.sessions.values()) { + if (!session.isAttachable) continue; // already terminating/dead + if (session.clientCount > 0) continue; // has attached clients + const meta = session.getMeta(); + const lastActive = new Date(meta.lastAttachedAt).getTime(); + if (now - lastActive > IDLE_SESSION_TIMEOUT_MS) { + console.log( + `[TerminalHost] Auto-killing idle session ${session.sessionId} (idle for ${Math.round((now - lastActive) / 60000)}min)`, + ); + this.kill({ sessionId: session.sessionId, deleteHistory: false }); + } + } + }, IDLE_SWEEP_INTERVAL_MS); + } + + private stopIdleSweep(): void { + if (this.idleSweepTimer) { + clearInterval(this.idleSweepTimer); + this.idleSweepTimer = null; + } + } + + private startRssSweep(): void { + this.rssSweepTimer = setInterval(() => { + void this.runRssSweep(); + }, RSS_SWEEP_INTERVAL_MS); + } + + private stopRssSweep(): void { + if (this.rssSweepTimer) { + clearInterval(this.rssSweepTimer); + this.rssSweepTimer = null; + } + } + + private async runRssSweep(): Promise { + const sessionsWithPid = Array.from(this.sessions.values()).filter( + (s) => s.isAttachable && s.pid !== null, + ); + if (sessionsWithPid.length === 0) return; + + let snapshot: ProcessSnapshot | undefined; + try { + snapshot = await captureProcessSnapshot(); + } catch (err) { + console.warn("[resource-metrics] captureProcessSnapshot failed:", err); + return; + } + + for (const session of sessionsWithPid) { + const pid = session.pid; + if (pid === null) continue; + const { memory } = getSubtreeResources(snapshot, pid); + if (memory > MAX_SESSION_RSS_BYTES) { + console.warn( + `[TerminalHost] Killing session ${session.sessionId} — RSS ${Math.round(memory / 1024 / 1024)} MB exceeds ${Math.round(MAX_SESSION_RSS_BYTES / 1024 / 1024)} MB limit`, + ); + this.kill({ sessionId: session.sessionId, deleteHistory: false }); + } + } } /** @@ -382,6 +464,9 @@ export class TerminalHost { } async dispose(): Promise { + this.stopIdleSweep(); + this.stopRssSweep(); + for (const pendingAttach of this.pendingAttaches.values()) { pendingAttach.abortController.abort(); } diff --git a/apps/desktop/src/renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx b/apps/desktop/src/renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx index 58cc75aa1fa..75bfa747a25 100644 --- a/apps/desktop/src/renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx +++ b/apps/desktop/src/renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx @@ -8,6 +8,8 @@ const queryClient = new QueryClient({ queries: { networkMode: "always", retry: false, + staleTime: 30_000, // 30s — avoids refetch on every mount + gcTime: 5 * 60 * 1000, // 5 minutes — explicit (matches TanStack Query default) }, mutations: { networkMode: "always", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index ed7656ab114..1691eadccc1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -1,11 +1,12 @@ import type { ExternalApp } from "@superset/local-db"; import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { useFileOpenMode } from "renderer/hooks/useFileOpenMode"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getWorkspaceDisplayName } from "renderer/lib/getWorkspaceDisplayName"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; +import { electronQueryClient } from "renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider"; import { usePresets } from "renderer/react-query/presets"; import type { WorkspaceSearchParams } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; @@ -88,6 +89,18 @@ export const Route = createFileRoute( function WorkspacePage() { const { workspaceId } = Route.useParams(); + + // Invalidate stale workspace queries when navigating between workspaces + const utils = electronTrpc.useUtils(); + const prevWorkspaceIdRef = useRef(null); + useEffect(() => { + const prevId = prevWorkspaceIdRef.current; + prevWorkspaceIdRef.current = workspaceId; + if (prevId !== null && prevId !== workspaceId) { + void utils.workspaces.get.invalidate({ id: workspaceId }); + } + }, [workspaceId, utils]); + const { data: workspace } = electronTrpc.workspaces.get.useQuery({ id: workspaceId, }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/index.ts index 65caa2bb36a..bf829837dda 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/index.ts @@ -1,4 +1,5 @@ export { destroyPersistentWebview, + lastActiveTimestamps, usePersistentWebview, } from "./usePersistentWebview"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts index cd9363802dc..5224a069890 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts @@ -9,6 +9,8 @@ import { useTabsStore } from "renderer/stores/tabs/store"; const webviewRegistry = new Map(); /** Tracks paneId → last-registered webContentsId so we can re-register if it changes. */ const registeredWebContentsIds = new Map(); +/** Tracks paneId → timestamp of last time the webview was visible (moved to hidden container). */ +export const lastActiveTimestamps = new Map(); let hiddenContainer: HTMLDivElement | null = null; function getHiddenContainer(): HTMLDivElement { @@ -64,6 +66,7 @@ export function destroyPersistentWebview(paneId: string): void { webviewRegistry.delete(paneId); } registeredWebContentsIds.delete(paneId); + lastActiveTimestamps.delete(paneId); } // --------------------------------------------------------------------------- @@ -102,7 +105,24 @@ export function usePersistentWebview({ const initialUrlRef = useRef(initialUrl); const navigateBrowserHistory = useTabsStore((s) => s.navigateBrowserHistory); + const resumeBrowserPane = useTabsStore((s) => s.resumeBrowserPane); const browserState = useTabsStore((s) => s.panes[paneId]?.browser); + const suspended = useTabsStore((s) => s.panes[paneId]?.suspended ?? false); + // True when this pane's tab is the active tab for its workspace. + // Used to gate webview creation so a suspended pane only recreates its + // webview when the user actually focuses it (not on every store change). + const isActiveTab = useTabsStore((s) => { + const pane = s.panes[paneId]; + if (!pane) return false; + return s.activeTabIds[pane.workspaceId] === pane.tabId; + }); + // Ref so the main lifecycle effect can read `suspended` at mount time without + // listing it as a dependency. If `suspended` were in the deps array, calling + // resumeBrowserPane() inside the effect would immediately re-trigger cleanup + // (parking the webview) and a new run (reclaiming it), causing a needless + // teardown/re-attach cycle on every restore-from-suspension. + const suspendedRef = useRef(suspended); + suspendedRef.current = suspended; const historyIndex = browserState?.historyIndex ?? 0; const historyLength = browserState?.history.length ?? 0; const canGoBack = historyIndex > 0; @@ -179,11 +199,14 @@ export function usePersistentWebview({ let webview = webviewRegistry.get(paneId); if (webview) { - // Reclaim from hidden container + // Reclaim from hidden container — update activity timestamp + lastActiveTimestamps.set(paneId, Date.now()); container.appendChild(webview); syncStoreFromWebview(webview); - } else { - // Create new webview + } else if (isActiveTab) { + // Create new webview only when this tab is active. + // For suspended panes in background tabs, skip creation until the user + // actually focuses the tab (isActiveTab becomes true → effect re-runs). webview = document.createElement("webview") as Electron.WebviewTag; webview.setAttribute("partition", "persist:superset"); webview.setAttribute("allowpopups", ""); @@ -196,10 +219,24 @@ export function usePersistentWebview({ webviewRegistry.set(paneId, webview); container.appendChild(webview); - const finalUrl = sanitizeUrl(initialUrlRef.current); - webview.src = finalUrl; + // If the pane was suspended (idle-unloaded), restore to last known URL. + // Read via ref — not via the `suspended` selector — to avoid adding + // `suspended` to the effect deps (which would cause a loop: calling + // resumeBrowserPane mutates suspended, re-triggering cleanup+effect). + const wasSuspended = suspendedRef.current; + const restoreUrl = wasSuspended + ? (useTabsStore.getState().panes[paneId]?.browser?.currentUrl ?? + initialUrlRef.current) + : initialUrlRef.current; + webview.src = sanitizeUrl(restoreUrl); + if (wasSuspended) resumeBrowserPane(paneId); } + // No webview exists and this tab is not active — nothing to do. + // When the user switches to this tab, isActiveTab becomes true and + // the effect re-runs, creating the webview at that point. + if (!webview) return; + const wv = webview; // -- Event handlers ------------------------------------------------ @@ -367,10 +404,26 @@ export function usePersistentWebview({ handleDidFailLoad as EventListener, ); - getHiddenContainer().appendChild(wv); + // Only park if the pane was not explicitly destroyed. + // destroyPersistentWebview removes paneId from webviewRegistry; + // re-parking after destruction would resurrect the removed node. + if (webviewRegistry.has(paneId)) { + lastActiveTimestamps.set(paneId, Date.now()); + getHiddenContainer().appendChild(wv); + } }; // paneId is stable for the lifetime of a pane; initialUrlRef only used on first create. - }, [paneId, registerBrowser, syncStoreFromWebview, upsertHistory]); + // isActiveTab triggers recreation when a suspended pane's tab is focused. + // suspended intentionally omitted — read via suspendedRef to prevent + // the resumeBrowserPane() call from re-triggering this effect. + }, [ + paneId, + isActiveTab, + registerBrowser, + resumeBrowserPane, + syncStoreFromWebview, + upsertHistory, + ]); // -- Navigation methods (operate directly on the webview) --------------- diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx index bd3547c6fb7..4ebdb318b05 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx @@ -268,6 +268,8 @@ export function ChatPaneInterface({ const { commands, messages, + hasMoreMessages = false, + loadAllMessages, currentMessage, isRunning = false, isConversationLoading = false, @@ -995,6 +997,8 @@ export function ChatPaneInterface({
(null); + + const handleLoadAllMessages = useCallback(() => { + if (!onLoadAllMessages) return; + // Walk up the DOM to find the first scrollable ancestor so we can + // restore the viewport position after the extra messages render. + let scrollEl: HTMLElement | null = messageListRef.current?.parentElement ?? null; + while (scrollEl) { + const { overflow, overflowY } = window.getComputedStyle(scrollEl); + if (/auto|scroll/.test(overflow + overflowY)) break; + scrollEl = scrollEl.parentElement; + } + const prevHeight = scrollEl?.scrollHeight ?? 0; + const prevTop = scrollEl?.scrollTop ?? 0; + onLoadAllMessages(); + if (scrollEl) { + const el = scrollEl; + requestAnimationFrame(() => { + el.scrollTop = prevTop + (el.scrollHeight - prevHeight); + }); + } + }, [onLoadAllMessages]); + const chatSearch = useChatMessageSearch({ containerRef: messageListRef, isFocused, @@ -174,6 +198,17 @@ export function ChatMessageList({
+ {hasMoreMessages && onLoadAllMessages && ( +
+ +
+ )} {shouldShowConversationLoading ? ( ) : shouldShowEmptyState ? ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.types.ts index 731578430bf..71317333c25 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.types.ts @@ -56,6 +56,10 @@ export interface UserMessageRestartRequest { export interface ChatMessageListProps { messages: ChatMessage[]; + /** True when there are older messages hidden behind the display limit */ + hasMoreMessages?: boolean; + /** Callback to load the full message history (removes the display limit) */ + onLoadAllMessages?: () => void; isFocused: boolean; isRunning: boolean; isConversationLoading: boolean; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useBrowserLifecycle/useBrowserLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useBrowserLifecycle/useBrowserLifecycle.ts index 9f77739461d..b9d5c774e22 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useBrowserLifecycle/useBrowserLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useBrowserLifecycle/useBrowserLifecycle.ts @@ -1,7 +1,16 @@ import { useEffect, useRef } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { destroyPersistentWebview } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview"; +import { + destroyPersistentWebview, + lastActiveTimestamps, +} from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; + +/** Destroy webviews that have been idle (parked in hidden container) for this long */ +const IDLE_WEBVIEW_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes +/** How often to check for idle webviews */ +const IDLE_SWEEP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes export function useBrowserLifecycle() { const { mutate: unregisterBrowser } = @@ -32,4 +41,51 @@ export function useBrowserLifecycle() { previousPaneIdsRef.current = currentBrowserPaneIds; }); }, [unregisterBrowser]); + + // Idle sweep: destroy webviews that have been parked for too long to free GPU memory. + // The webview will be transparently recreated when the user focuses the pane again. + useEffect(() => { + const sweep = setInterval(() => { + const { panes, activeTabIds, tabs } = useTabsStore.getState(); + // Build the set of pane IDs that are visible in any active tab's layout. + // A pane is visible if it appears in the mosaic of the currently active tab + // for its workspace — this covers split layouts where multiple panes are + // visible simultaneously but only one holds keyboard focus. + const activeTabIdSet = new Set(Object.values(activeTabIds)); + const visiblePaneIds = new Set(); + for (const tab of tabs) { + if (activeTabIdSet.has(tab.id)) { + for (const id of extractPaneIdsFromLayout(tab.layout)) { + visiblePaneIds.add(id); + } + } + } + const now = Date.now(); + + for (const [paneId, pane] of Object.entries(panes)) { + if (pane.type !== "webview") continue; + if (pane.suspended) continue; // already suspended + if (visiblePaneIds.has(paneId)) { + // Pane became visible again — clear hidden-since timestamp so it + // doesn't accumulate idle time while it was briefly off-screen. + lastActiveTimestamps.delete(paneId); + continue; + } + + // Record the first time this pane is observed as hidden (parked). + // Avoid falling back to `now` — that would reset the clock each sweep. + if (!lastActiveTimestamps.has(paneId)) { + lastActiveTimestamps.set(paneId, now); + } + const lastActive = lastActiveTimestamps.get(paneId)!; + if (now - lastActive > IDLE_WEBVIEW_TIMEOUT_MS) { + destroyPersistentWebview(paneId); + unregisterBrowser({ paneId }); + useTabsStore.getState().suspendBrowserPane(paneId); + } + } + }, IDLE_SWEEP_INTERVAL_MS); + + return () => clearInterval(sweep); + }, [unregisterBrowser]); } diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 8fc0f13a086..9a6eff787a4 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -1934,6 +1934,30 @@ export const useTabsStore = create()( }); }, + suspendBrowserPane: (paneId) => { + const state = get(); + const pane = state.panes[paneId]; + if (!pane || pane.type !== "webview") return; + set({ + panes: { + ...state.panes, + [paneId]: { ...pane, suspended: true }, + }, + }); + }, + + resumeBrowserPane: (paneId) => { + const state = get(); + const pane = state.panes[paneId]; + if (!pane || pane.type !== "webview") return; + set({ + panes: { + ...state.panes, + [paneId]: { ...pane, suspended: false }, + }, + }); + }, + openDevToolsPane: (tabId, browserPaneId, path) => { const state = get(); const tab = state.tabs.find((t) => t.id === tabId); diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index dd344bf3812..a9b92dc9e74 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -216,6 +216,8 @@ export interface TabsStore extends TabsState { updateBrowserLoading: (paneId: string, isLoading: boolean) => void; setBrowserError: (paneId: string, error: BrowserLoadError | null) => void; setBrowserViewport: (paneId: string, viewport: ViewportPreset | null) => void; + suspendBrowserPane: (paneId: string) => void; + resumeBrowserPane: (paneId: string) => void; openDevToolsPane: ( tabId: string, browserPaneId: string, diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index c19264f803c..1d505284cd3 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -39,6 +39,7 @@ export const MOCK_ORG_ID = "mock-org-id"; // Terminal defaults export const DEFAULT_TERMINAL_SCROLLBACK = 5000; +export const MAX_TERMINAL_SCROLLBACK = 10_000; // Default user preference values export const DEFAULT_CONFIRM_ON_QUIT = true; diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index d02b081ba9c..0c7f5fabf31 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -142,6 +142,7 @@ export interface Pane { chat?: ChatPaneState; // For chat panes browser?: BrowserPaneState; // For browser (webview) panes devtools?: DevToolsPaneState; // For devtools panes + suspended?: boolean; // True when webview has been unloaded to free GPU memory workspaceRun?: { workspaceId: string; state: "running" | "stopped-by-user" | "stopped-by-exit"; diff --git a/packages/chat/src/client/hooks/use-chat-display/use-chat-display.ts b/packages/chat/src/client/hooks/use-chat-display/use-chat-display.ts index 84d112bdfc7..6cf6db11f35 100644 --- a/packages/chat/src/client/hooks/use-chat-display/use-chat-display.ts +++ b/packages/chat/src/client/hooks/use-chat-display/use-chat-display.ts @@ -1,9 +1,14 @@ import { skipToken } from "@tanstack/react-query"; import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ChatRuntimeServiceRouter } from "../../../server/trpc"; import { chatRuntimeServiceTrpc } from "../../provider"; +/** Maximum number of messages rendered in the DOM at once. Older messages are + * hidden behind a "Load earlier messages" button to reduce DOM node count and + * memory pressure. Users can opt-in to see the full history on demand. */ +const MAX_DISPLAYED_MESSAGES = 100; + type RouterInputs = inferRouterInputs; type RouterOutputs = inferRouterOutputs; @@ -156,6 +161,11 @@ export function useChatDisplay(options: UseChatDisplayOptions) { const latestAssistantErrorMessage = isRunning ? null : findLatestAssistantErrorMessage(historicalMessages); + const [showAllMessages, setShowAllMessages] = useState(false); + useEffect(() => { + if (!sessionId) return; + setShowAllMessages(false); + }, [sessionId]); const [optimisticUserMessage, setOptimisticUserMessage] = useState< ListMessagesOutput[number] | null >(null); @@ -194,7 +204,7 @@ export function useChatDisplay(options: UseChatDisplayOptions) { fileMessageCountAtSendRef.current = null; }, [historicalMessages]); - const messages = useMemo(() => { + const allMessages = useMemo(() => { const withOptimistic = optimisticUserMessage ? [...historicalMessages, optimisticUserMessage] : historicalMessages; @@ -205,6 +215,17 @@ export function useChatDisplay(options: UseChatDisplayOptions) { }); }, [historicalMessages, optimisticUserMessage, currentMessage, isRunning]); + const hasMoreMessages = + !showAllMessages && allMessages.length > MAX_DISPLAYED_MESSAGES; + const messages = useMemo( + () => + hasMoreMessages + ? allMessages.slice(-MAX_DISPLAYED_MESSAGES) + : allMessages, + [allMessages, hasMoreMessages], + ); + const loadAllMessages = useCallback(() => setShowAllMessages(true), []); + const commands = useMemo( () => ({ sendMessage: async ( @@ -350,6 +371,8 @@ export function useChatDisplay(options: UseChatDisplayOptions) { return { ...displayState, messages, + hasMoreMessages, + loadAllMessages, isConversationLoading, error: runtimeErrorMessage ??