diff --git a/.agents/skills/ade-perf-lanes/SKILL.md b/.agents/skills/ade-perf-lanes/SKILL.md index a681ace02..ab3383c6a 100644 --- a/.agents/skills/ade-perf-lanes/SKILL.md +++ b/.agents/skills/ade-perf-lanes/SKILL.md @@ -7,7 +7,7 @@ description: Performance practices for ADE's Lanes tab. Read before editing measured UI audit proves a better one. metadata: author: ade-autoresearch - version: 0.2.0 + version: 0.2.2 status: active --- @@ -89,3 +89,27 @@ Use this as engineering guidance for keeping the Lanes tab fast while adding fea - **Apply when**: New lane metadata or appearance handlers update color/name/description without changing branch state. - **Avoid**: Bare `refreshLanes()` after appearance-only updates. - **Verification**: Real UI manage-dialog trace `lanes-refresh-light-20260511` showed `ade.lanes.updateAppearance` at 1 ms followed by an unnecessary `ade.lanes.listSnapshots` at 308 ms. The lightweight path uses `refreshLanes({ includeStatus: false })` instead. + +### Gate Stack Graph agent rosters behind visibility +- **Why it helped**: Lanes loaded per-lane agent rosters even while Stack Graph was closed. With 30 lanes, initial `/lanes` load fanned out `agentChat.list({ laneId })` and `sessions.list({ laneId })` for every lane before the user opened the graph. +- **Apply when**: A closed Lanes surface computes per-lane chat/session/agent data that is only rendered inside Stack Graph. +- **Avoid**: Fetching every lane's agent roster on page load to keep a closed dropdown warm. +- **Verification**: `lanes-20260531-1421-baseline` Lanes nav spent 87 `ade.localRuntime.callAction` calls / 25.3 s total IPC time. After gating rosters until Stack Graph opens, `lanes-20260531-1421-after1` dropped Lanes nav to 28 `callAction` calls / 13.3 s. Stack Graph then paid the roster cost on demand: 63 calls / 450 ms in the open segment. + +### Filter chat session lists before applying caps +- **Why it helped**: `agentChat.listSessions()` loaded the newest 500 terminal sessions and then filtered to chat rows. Many newer CLI or shell sessions could push older chats out of the capped result, making chat panes look empty or stale even though persisted chats existed. +- **Apply when**: A session list is intended to show a specific tool family, provider, or surface and the underlying table also stores high-volume shell/run-owned sessions. +- **Avoid**: Applying a global `limit` before the meaningful filter, or widening limits as a substitute for the right query. +- **Verification**: `fix(chat): filter chat session lists before caps` adds `toolTypes` to `sessionService.list`, uses it from `agentChatService.listSessions`, preserves legacy chat rows inferred from resume commands, and covers a regression with 505 newer shell sessions hiding an older chat. + +### Refresh visible linked PRs opportunistically +- **Why it helped**: Lanes PR badges and attached-PR status depended on the broad PR poller, so mergeability/check/review state could sit stale for about a minute after opening or switching lanes. +- **Apply when**: A Lanes surface needs attached PR status for lanes already visible in the grid. Refresh only linked, stale PR ids for visible lanes, dedupe them, cap the batch, and track recent request timestamps so scrolling or layout churn cannot stampede GitHub. +- **Avoid**: Kicking the global PR poller, refreshing GitHub-only PR rows without lane links, or widening the full PR refresh cadence to make one visible grid feel fresher. +- **Verification**: `perf(lanes): refresh visible PR status opportunistically` refreshes at most 4 stale visible linked PRs after a 260 ms debounce with a 15 s stale/request gate, merges returned summaries into Lanes tags, and covers selection/dedupe/cap behavior in `LanesPage.test.ts`. + +### Pause minimized and delayed Git Actions effects +- **Why it helped**: Minimized pane bodies were CSS-hidden but still mounted, so Git Actions kept diff/stash/sync/conflict loads, auto-rebase status, sync-status polling, event subscriptions, and commit-history requests alive in the background. Multiple visible lanes could also mount Git Actions panes at once. +- **Apply when**: A pane body owns timers, subscriptions, local Git reads, PR/AI/runtime status, or history loads. Pass pane minimized state into the render path, make child effects explicitly inactive while minimized, and stagger non-primary visible pane mounts so only the immediate lane warms eagerly. +- **Avoid**: Treating visual collapse as inactive, or adding a single page-level throttle while hidden pane components keep their own timers running. +- **Verification**: `perf(lanes): pause minimized git actions panes` adds `PaneConfig.renderChildren`, passes `active={!minimized}` to `LaneGitActionsPane` and `CommitTimeline`, staggers inline Git Actions bodies by visible-lane order, and covers inactive/active transitions in component tests. A real Electron `/lanes` segment, `lanes-minimized-git-idle` in `lanes-20260531-1421-throttles-after3`, kept the Git Actions pane minimized for 33.4 s with no slow Git Actions status/history IPC; main/browser p95 CPU was 0.15% and renderer tab p95 was 0.05%. diff --git a/README.md b/README.md index 06b033d51..2cb76769e 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,19 @@ ADE_DEV_RUNTIME_SOCKET_PATH=/tmp/my-ade-dev.sock npm run dev:runtime ADE_DESKTOP_BRIDGE_SOCKET_PATH=/tmp/my-bridge.sock npm run dev:desktop ``` +When launching ADE desktop dev through ADE App Control from a running Alpha/Beta +ADE window, use an absolute lane cwd and clear packaged-channel environment +variables inherited from the host app. Otherwise the dev Electron app can reuse +the Alpha/Beta profile and lose the single-instance lock instead of opening the +lane build: + +```bash +ade --socket app-control launch --force \ + --cwd "/path/to/ADE/.ade/worktrees/" \ + --command "sh -lc 'ADE_PACKAGE_CHANNEL= ADE_DESKTOP_APP_NAME= ADE_DESKTOP_BRIDGE_SOCKET_PATH=/tmp/ade-desktop-bridge-.sock npm run dev:desktop -- --socket /tmp/ade-runtime-.sock'" \ + --text +``` + To test auto-runtime creation, use the default dev commands after stopping the dev runtime: ```bash diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 62a2c578c..d201dea2b 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -6055,6 +6055,11 @@ app.whenReady().then(async () => { } return ctx; }, + getResourceUsageContexts: () => { + const contexts = new Set(projectContexts.values()); + contexts.add(getActiveContext()); + return Array.from(contexts); + }, getSyncService: () => { return getMobileSyncService(); }, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 2a3e54e6f..bc38f11b3 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1142,6 +1142,9 @@ function createMockSessionService() { if (typeof opts?.status === "string") { rows = rows.filter((row) => row.status === opts.status); } + if (Array.isArray(opts?.toolTypes) && opts.toolTypes.length > 0) { + rows = rows.filter((row) => opts.toolTypes.includes(row.toolType)); + } rows = rows.sort((a, b) => String(b.startedAt ?? "").localeCompare(String(a.startedAt ?? ""))); if (opts?.limit === null) return rows; const limit = typeof opts?.limit === "number" ? opts.limit : 200; @@ -3697,6 +3700,34 @@ describe("createAgentChatService", () => { expect(sessions[0]!.provider).toBe("opencode"); }); + it("lists chat sessions even when newer shell sessions exceed the terminal list cap", async () => { + const { service, sessionService } = createService(); + + const chat = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5-codex", + }); + + for (let i = 0; i < 505; i++) { + sessionService.create({ + sessionId: `shell-session-${i}`, + laneId: "lane-1", + toolType: "shell", + title: `Shell ${i}`, + startedAt: new Date(Date.UTC(2026, 2, 17, 0, 10, i)).toISOString(), + }); + } + + const sessions = await service.listSessions("lane-1"); + expect(sessions.map((session) => session.sessionId)).toContain(chat.id); + expect(sessionService.list).toHaveBeenLastCalledWith(expect.objectContaining({ + laneId: "lane-1", + limit: 500, + toolTypes: expect.arrayContaining(["codex-chat", "claude-chat", "opencode-chat", "cursor", "droid-chat"]), + })); + }); + it("excludes identity sessions by default", async () => { const { service } = createService(); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 854131749..147538329 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -2548,16 +2548,19 @@ function describeCodexModel(value: string): string | null { return null; } +const CHAT_SESSION_TOOL_TYPES = [ + "codex-chat", + "claude-chat", + "opencode-chat", + "cursor", + "droid-chat", +] satisfies TerminalToolType[]; +type ChatSessionToolType = (typeof CHAT_SESSION_TOOL_TYPES)[number]; + function isChatToolType( toolType: TerminalToolType | null | undefined, -): toolType is "codex-chat" | "claude-chat" | "opencode-chat" | "cursor" | "droid-chat" { - return ( - toolType === "codex-chat" - || toolType === "claude-chat" - || toolType === "opencode-chat" - || toolType === "cursor" - || toolType === "droid-chat" - ); +): toolType is ChatSessionToolType { + return toolType != null && CHAT_SESSION_TOOL_TYPES.includes(toolType as ChatSessionToolType); } function providerFromToolType(toolType: TerminalToolType | null | undefined): AgentChatProvider { @@ -21785,7 +21788,11 @@ export function createAgentChatService(args: { laneId?: string, options?: { includeIdentity?: boolean; includeAutomation?: boolean; includeArchived?: boolean }, ): Promise => { - const rows = sessionService.list({ ...(laneId ? { laneId } : {}), limit: 500 }); + const rows = sessionService.list({ + ...(laneId ? { laneId } : {}), + limit: 500, + toolTypes: CHAT_SESSION_TOOL_TYPES, + }); const chatRows = rows.filter((row) => isChatToolType(row.toolType)); const includeIdentity = options?.includeIdentity === true; const includeAutomation = options?.includeAutomation === true; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index e95bc2888..6295c4760 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -33,6 +33,8 @@ import type { AdoptAttachedLaneArgs, UnregisteredLaneCandidate, AppInfo, + AppResourceUsageSnapshot, + PtyProcessResourceUsageSnapshot, LatestReleaseInfo, ClearLocalAdeDataArgs, ClearLocalAdeDataResult, @@ -551,7 +553,8 @@ import type { createAutoRebaseService } from "../lanes/autoRebaseService"; import type { LaneWorktreeLockService } from "../lanes/laneWorktreeLockService"; import type { createSessionService } from "../sessions/sessionService"; import type { SessionDeltaService } from "../sessions/sessionDeltaService"; -import type { createPtyService } from "../pty/ptyService"; +import { readProcessMetricRows, sampleProcessTreeResourceUsage } from "../pty/ptyService"; +import type { createPtyService, ProcessMetricRowsProvider } from "../pty/ptyService"; import { type createDiffService, MAX_DIFF_SIDE_TEXT_BYTES, @@ -657,6 +660,246 @@ import { probeLocalhostPort } from "../probeLocalhostPort"; import type { ProcessRegistryService } from "../runtime/processRegistryService"; import { deleteMacosVmFromProjectState } from "../macosVm/macosVmRecovery"; +type ElectronProcessMetric = ReturnType[number]; +const APP_RESOURCE_USAGE_CACHE_MS = 900; +let appResourceUsageCache: { + contexts: AppResourceUsageContext[]; + localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null; + sampledAtMs: number; + snapshot: AppResourceUsageSnapshot; +} | null = null; + +type AppResourceUsageContext = Pick; + +function roundMetric(value: number | null | undefined, digits = 1): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +} + +function sumMetricCpu(metrics: ElectronProcessMetric[]): number | null { + let total = 0; + let seen = false; + for (const metric of metrics) { + const value = metric.cpu?.percentCPUUsage; + if (typeof value !== "number" || !Number.isFinite(value)) continue; + total += value; + seen = true; + } + return seen ? roundMetric(total) : null; +} + +function sumMetricMemoryMB(metrics: ElectronProcessMetric[]): number | null { + let totalKb = 0; + let seen = false; + for (const metric of metrics) { + const value = metric.memory?.workingSetSize; + if (typeof value !== "number" || !Number.isFinite(value)) continue; + totalKb += value; + seen = true; + } + return seen ? roundMetric(totalKb / 1024) : null; +} + +function sumOptionalMetrics(...values: Array): number | null { + let total = 0; + let seen = false; + for (const value of values) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + total += value; + seen = true; + } + return seen ? roundMetric(total) : null; +} + +function emptyPtyResourceUsage(): PtyProcessResourceUsageSnapshot { + return { + activePtyCount: 0, + ptyProcessCount: 0, + ptyCpuPercent: 0, + ptyMemoryMB: 0, + }; +} + +function combinePtyResourceUsage( + first: PtyProcessResourceUsageSnapshot, + second: PtyProcessResourceUsageSnapshot, +): PtyProcessResourceUsageSnapshot { + const sumUsageMetric = ( + metric: "ptyCpuPercent" | "ptyMemoryMB", + ): number | null => { + const firstActive = first.activePtyCount > 0 || first.ptyProcessCount > 0; + const secondActive = second.activePtyCount > 0 || second.ptyProcessCount > 0; + if (firstActive && first[metric] == null) return null; + if (secondActive && second[metric] == null) return null; + return sumOptionalMetrics(first[metric], second[metric]); + }; + + return { + activePtyCount: first.activePtyCount + second.activePtyCount, + ptyProcessCount: first.ptyProcessCount + second.ptyProcessCount, + ptyCpuPercent: sumUsageMetric("ptyCpuPercent"), + ptyMemoryMB: sumUsageMetric("ptyMemoryMB"), + }; +} + +function collectRuntimeOwnedPtyRoots( + sessionService?: ReturnType | null, + processRegistry?: ProcessRegistryService | null, +): { activePtyCount: number; ownerPids: number[] } { + const isLiveSessionOwner = (session: { + ownerPid?: number | null; + ownerProcessStartedAt?: string | null; + }): boolean => { + if (!processRegistry) return false; + if (session.ownerPid == null || session.ownerPid === process.pid) return false; + const startedAt = typeof session.ownerProcessStartedAt === "string" + ? session.ownerProcessStartedAt.trim() + : ""; + return startedAt + ? processRegistry.isProcessIdentityLive(session.ownerPid, startedAt) + : processRegistry.isPidLive(session.ownerPid); + }; + const runningSessions = sessionService + ? sessionService + .list({ status: "running", limit: null }) + .filter(isLiveSessionOwner) + : []; + return { + activePtyCount: runningSessions.length, + ownerPids: runningSessions + .map((session) => session.ownerPid) + .filter((pid): pid is number => typeof pid === "number" && Number.isFinite(pid) && pid > 0 && pid !== process.pid), + }; +} + +function getRuntimeOwnedPtyUsage( + contexts: AppResourceUsageContext[], + localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null, + readRows?: ProcessMetricRowsProvider, +): PtyProcessResourceUsageSnapshot { + let activePtyCount = 0; + const ownerPids = new Set(); + for (const ctx of contexts) { + const roots = collectRuntimeOwnedPtyRoots(ctx.sessionService, ctx.processRegistry); + activePtyCount += roots.activePtyCount; + for (const pid of roots.ownerPids) ownerPids.add(pid); + } + for (const pid of localRuntimeConnectionPool?.getRuntimeProcessIds?.() ?? []) { + if (typeof pid === "number" && Number.isFinite(pid) && pid > 0 && pid !== process.pid) { + ownerPids.add(pid); + } + } + return sampleProcessTreeResourceUsage(Array.from(ownerPids), activePtyCount, readRows); +} + +function createSharedProcessMetricRowsProvider(): ProcessMetricRowsProvider { + let rows: ReturnType | undefined; + return () => { + if (rows === undefined) rows = readProcessMetricRows(); + return rows; + }; +} + +function processMetricKind(metric: ElectronProcessMetric): string { + return String(metric.type ?? "").toLowerCase(); +} + +function isMainProcessMetric(metric: ElectronProcessMetric): boolean { + return processMetricKind(metric) === "browser"; +} + +function isRendererProcessMetric(metric: ElectronProcessMetric): boolean { + const kind = processMetricKind(metric); + return kind === "renderer" || kind === "tab"; +} + +function getSystemMemoryMB(): { freeMemoryMB: number | null; totalMemoryMB: number | null } { + const electronProcess = process as NodeJS.Process & { + getSystemMemoryInfo?: () => { free?: number; total?: number }; + }; + const read = electronProcess.getSystemMemoryInfo; + if (typeof read !== "function") { + return { freeMemoryMB: null, totalMemoryMB: null }; + } + try { + const info = read(); + return { + freeMemoryMB: roundMetric((info.free ?? 0) / 1024, 0), + totalMemoryMB: roundMetric((info.total ?? 0) / 1024, 0), + }; + } catch { + return { freeMemoryMB: null, totalMemoryMB: null }; + } +} + +function getAppResourceUsageSnapshot( + contexts: AppResourceUsageContext[], + localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null, +): AppResourceUsageSnapshot { + const metrics = app.getAppMetrics(); + const mainMetrics = metrics.filter(isMainProcessMetric); + const rendererMetrics = metrics.filter(isRendererProcessMetric); + const readRows = createSharedProcessMetricRowsProvider(); + const ptyServiceUsage = contexts.reduce( + (usage, ctx) => combinePtyResourceUsage( + usage, + ctx.ptyService?.getResourceUsageSnapshot?.(readRows) ?? emptyPtyResourceUsage(), + ), + emptyPtyResourceUsage(), + ); + const ptyUsage = combinePtyResourceUsage( + ptyServiceUsage, + getRuntimeOwnedPtyUsage(contexts, localRuntimeConnectionPool, readRows), + ); + const electronCpuPercent = sumMetricCpu(metrics); + const electronMemoryMB = sumMetricMemoryMB(metrics); + const memory = getSystemMemoryMB(); + return { + ...ptyUsage, + sampledAt: new Date().toISOString(), + processCount: metrics.length + ptyUsage.ptyProcessCount, + cpuPercent: sumOptionalMetrics(electronCpuPercent, ptyUsage.ptyCpuPercent), + mainCpuPercent: sumMetricCpu(mainMetrics), + rendererCpuPercent: sumMetricCpu(rendererMetrics), + memoryMB: sumOptionalMetrics(electronMemoryMB, ptyUsage.ptyMemoryMB), + mainMemoryMB: sumMetricMemoryMB(mainMetrics), + rendererMemoryMB: sumMetricMemoryMB(rendererMetrics), + freeMemoryMB: memory.freeMemoryMB, + totalMemoryMB: memory.totalMemoryMB, + }; +} + +function sameAppResourceUsageContexts( + first: AppResourceUsageContext[], + second: AppResourceUsageContext[], +): boolean { + return first.length === second.length && first.every((ctx, index) => ctx === second[index]); +} + +function getCachedAppResourceUsageSnapshot( + contexts: AppResourceUsageContext[], + localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null, +): AppResourceUsageSnapshot { + const now = Date.now(); + if ( + appResourceUsageCache + && sameAppResourceUsageContexts(appResourceUsageCache.contexts, contexts) + && appResourceUsageCache.localRuntimeConnectionPool === localRuntimeConnectionPool + && now - appResourceUsageCache.sampledAtMs < APP_RESOURCE_USAGE_CACHE_MS + ) { + return appResourceUsageCache.snapshot; + } + const snapshot = getAppResourceUsageSnapshot(contexts, localRuntimeConnectionPool); + appResourceUsageCache = { + contexts: [...contexts], + localRuntimeConnectionPool, + sampledAtMs: now, + snapshot, + }; + return snapshot; +} + export type AppContext = { db: AdeDb | null; logger: Logger; @@ -1299,6 +1542,7 @@ function buildIssueResolutionInstructionsFromThread(arg: LaunchPrIssueResolution export function registerIpc({ getCtx, + getResourceUsageContexts, getSyncService, resolveSyncService, runWithIpcWindow, @@ -1315,6 +1559,7 @@ export function registerIpc({ builtInBrowserService, }: { getCtx: () => AppContext; + getResourceUsageContexts?: () => AppContext[]; getSyncService?: () => ReturnType | null | undefined; resolveSyncService?: () => Promise | null | undefined>; runWithIpcWindow?: (event: { sender: Electron.WebContents }, fn: () => T | Promise) => T | Promise; @@ -3317,6 +3562,15 @@ export function registerIpc({ }; }); + ipcMain.handle(IPC.appGetResourceUsage, async (): Promise => { + const ctx = getCtx(); + const contexts = getResourceUsageContexts?.() ?? [ctx]; + return getCachedAppResourceUsageSnapshot( + contexts.length > 0 ? contexts : [ctx], + localRuntimeConnectionPool, + ); + }); + ipcMain.handle(IPC.appGetLatestRelease, async (): Promise => { let token: string | null = null; try { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index a9539a22e..41ea49a9d 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -494,6 +494,7 @@ export class LocalRuntimeConnectionPool { private connection: Promise | null = null; private activeConnection: LocalRuntimeConnection | null = null; private activeClient: RuntimeRpcClient | null = null; + private activeRuntimePid: number | null = null; private ownedRuntimeChild: ChildProcess | null = null; private preserveOwnedRuntimeChildOnNextConnect = false; private readonly coalescedActionCalls = new Map>(); @@ -539,6 +540,17 @@ export class LocalRuntimeConnectionPool { }; } + getRuntimeProcessIds(): number[] { + const pids = [ + this.activeRuntimePid, + this.activeConnection?.child?.pid, + this.ownedRuntimeChild?.pid, + ].filter((pid): pid is number => ( + typeof pid === "number" && Number.isFinite(pid) && pid > 0 && pid !== process.pid + )); + return Array.from(new Set(pids)); + } + noteServiceInstallSkipped(message: string): void { this.serviceInstallStatus = { state: "skipped", @@ -998,6 +1010,7 @@ export class LocalRuntimeConnectionPool { this.connection = null; this.activeConnection = null; this.activeClient = null; + this.activeRuntimePid = null; this.ownedRuntimeChild = null; this.preserveOwnedRuntimeChildOnNextConnect = false; this.projectsByRoot.clear(); @@ -1019,6 +1032,7 @@ export class LocalRuntimeConnectionPool { this.connection = null; this.activeConnection = null; this.activeClient = null; + this.activeRuntimePid = null; } throw error; }); @@ -1035,6 +1049,7 @@ export class LocalRuntimeConnectionPool { this.connection = null; this.activeConnection = null; this.activeClient = null; + this.activeRuntimePid = null; this.projectsByRoot.clear(); return true; } @@ -1207,6 +1222,7 @@ export class LocalRuntimeConnectionPool { ); } this.activeClient = client; + this.activeRuntimePid = runtimeInfo.pid; client.onDisconnect((error) => { if (this.activeClient !== client && this.activeConnection?.client !== client) return; this.logger.warn("local_runtime.disconnected", { @@ -1216,6 +1232,7 @@ export class LocalRuntimeConnectionPool { this.connection = null; this.activeConnection = null; this.activeClient = null; + this.activeRuntimePid = null; this.projectsByRoot.clear(); }); return client; @@ -1258,6 +1275,7 @@ export class LocalRuntimeConnectionPool { this.connection = null; this.activeConnection = null; this.activeClient = null; + this.activeRuntimePid = null; this.projectsByRoot.clear(); if (client) closeRuntimeClient(client); }; diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 44690268a..3d1bee6e2 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -413,6 +413,52 @@ describe("ptyService", () => { mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" }); }); + describe("resource usage snapshots", () => { + it("uses supplied process metric rows for active PTYs", async () => { + const { service } = createHarness(); + + await service.create({ + laneId: "lane-1", + title: "Tracked shell", + cols: 80, + rows: 24, + }); + mocks.spawnSync.mockClear(); + + const readRows = vi.fn(() => [ + { pid: 12345, ppid: 1, cpuPercent: 10, rssKB: 1024 }, + { pid: 23456, ppid: 12345, cpuPercent: 2.5, rssKB: 2048 }, + { pid: 34567, ppid: 1, cpuPercent: 80, rssKB: 4096 }, + ]); + + const usage = service.getResourceUsageSnapshot(readRows); + + expect(readRows).toHaveBeenCalledTimes(1); + expect(mocks.spawnSync).not.toHaveBeenCalled(); + expect(usage).toEqual({ + activePtyCount: 1, + ptyProcessCount: 2, + ptyCpuPercent: 12.5, + ptyMemoryMB: 3, + }); + }); + + it("does not sample process metrics when no PTYs are active", () => { + const { service } = createHarness(); + const readRows = vi.fn(() => { + throw new Error("should not sample"); + }); + + expect(service.getResourceUsageSnapshot(readRows)).toEqual({ + activePtyCount: 0, + ptyProcessCount: 0, + ptyCpuPercent: 0, + ptyMemoryMB: 0, + }); + expect(readRows).not.toHaveBeenCalled(); + }); + }); + describe("ensureNodePtySpawnHelperExecutable", () => { it("adds executable bits to the Darwin node-pty spawn helper", () => { const packageRoot = "/tmp/node-pty"; diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index e58e7f80a..6e969e7de 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -48,7 +48,8 @@ import type { TerminalRuntimeState, TerminalSessionStatus, TerminalSessionSummary, - TerminalToolType + TerminalToolType, + PtyProcessResourceUsageSnapshot, } from "../../../shared/types"; import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; import { withCodexNoAltScreen } from "../../../shared/cliLaunch"; @@ -140,6 +141,135 @@ const AGENT_CLI_READY_QUIET_MS = 600; const PTY_PROCESS_TREE_KILL_DELAY_MS = 1500; const PTY_PROCESS_TREE_MAX_DEPTH = 12; +export type ProcessMetricRow = { + pid: number; + ppid: number; + cpuPercent: number; + rssKB: number; +}; + +export type ProcessMetricRowsProvider = () => ProcessMetricRow[] | null; + +function roundUsageMetric(value: number | null | undefined, digits = 1): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +} + +export function readProcessMetricRows(): ProcessMetricRow[] | null { + try { + const result = spawnSync("ps", ["-axo", "pid=,ppid=,pcpu=,rss="], { + encoding: "utf8", + timeout: 1000, + }); + if (result.error || result.status !== 0) return null; + return String(result.stdout ?? "") + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [pidRaw, ppidRaw, cpuRaw, rssRaw] = line.split(/\s+/u); + const pid = Number.parseInt(pidRaw ?? "", 10); + const ppid = Number.parseInt(ppidRaw ?? "", 10); + const cpuPercent = Number.parseFloat(cpuRaw ?? ""); + const rssKB = Number.parseInt(rssRaw ?? "", 10); + if ( + !Number.isFinite(pid) + || pid <= 0 + || !Number.isFinite(ppid) + || ppid < 0 + || !Number.isFinite(cpuPercent) + || !Number.isFinite(rssKB) + || rssKB < 0 + ) { + return null; + } + return { pid, ppid, cpuPercent, rssKB }; + }) + .filter((row): row is ProcessMetricRow => row != null); + } catch { + return null; + } +} + +function collectProcessTreePids( + rootPids: number[], + rows: ProcessMetricRow[], +): Set { + const byParent = new Map(); + const knownPids = new Set(); + for (const row of rows) { + knownPids.add(row.pid); + const children = byParent.get(row.ppid) ?? []; + children.push(row.pid); + byParent.set(row.ppid, children); + } + + const seen = new Set(); + let frontier = rootPids + .map((pid) => Math.trunc(pid)) + .filter((pid) => Number.isFinite(pid) && pid > 0 && knownPids.has(pid)); + for (const pid of frontier) seen.add(pid); + + for (let depth = 0; depth < PTY_PROCESS_TREE_MAX_DEPTH && frontier.length > 0; depth += 1) { + const next: number[] = []; + for (const parent of frontier) { + for (const child of byParent.get(parent) ?? []) { + if (seen.has(child)) continue; + seen.add(child); + next.push(child); + } + } + frontier = next; + } + return seen; +} + +export function sampleProcessTreeResourceUsage( + rootPids: number[], + activePtyCount = rootPids.length, + readRows: ProcessMetricRowsProvider = readProcessMetricRows, +): PtyProcessResourceUsageSnapshot { + if (activePtyCount === 0 && rootPids.length === 0) { + return { + activePtyCount: 0, + ptyProcessCount: 0, + ptyCpuPercent: 0, + ptyMemoryMB: 0, + }; + } + + const rows = readRows(); + if (!rows) { + return { + activePtyCount, + ptyProcessCount: 0, + ptyCpuPercent: null, + ptyMemoryMB: null, + }; + } + + const ptyProcessPids = collectProcessTreePids(rootPids, rows); + const rowsByPid = new Map(rows.map((row) => [row.pid, row])); + let cpuPercent = 0; + let rssKB = 0; + let processCount = 0; + for (const pid of ptyProcessPids) { + const row = rowsByPid.get(pid); + if (!row) continue; + processCount += 1; + cpuPercent += row.cpuPercent; + rssKB += row.rssKB; + } + + return { + activePtyCount, + ptyProcessCount: processCount, + ptyCpuPercent: roundUsageMetric(cpuPercent), + ptyMemoryMB: roundUsageMetric(rssKB / 1024), + }; +} + let cachedOpenCodeReplayResumeSupport: boolean | null = null; function isPidLive(pid: number): boolean { @@ -994,6 +1124,15 @@ export function createPtyService({ const ownerPid = processRegistry?.pid ?? null; const ownerProcessStartedAt = processRegistry?.startedAt ?? null; + const getResourceUsageSnapshot = (readRows?: ProcessMetricRowsProvider): PtyProcessResourceUsageSnapshot => { + const liveEntries = Array.from(ptys.values()).filter((entry) => !entry.disposed); + const activePtyCount = liveEntries.length; + const rootPids = liveEntries + .map((entry) => entry.pty.pid) + .filter((pid): pid is number => typeof pid === "number" && Number.isFinite(pid) && pid > 0); + return sampleProcessTreeResourceUsage(rootPids, activePtyCount, readRows); + }; + const isOwnedByLivePeerRuntime = (session: { ownerPid?: number | null; ownerProcessStartedAt?: string | null; @@ -4712,6 +4851,8 @@ export function createPtyService({ return false; }, + getResourceUsageSnapshot, + onData(listener: PtyDataListener): () => void { dataListeners.add(listener); return () => { diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index ad1f9ba19..3a71633a0 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -426,6 +426,44 @@ describe("sessionService resume metadata", () => { activeDisposers.push(async () => db.close()); }); + it("applies tool type filters before the session list limit", async () => { + const projectRoot = makeProjectRoot("ade-session-service-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(db); + const service = createSessionService({ db }); + + service.create({ + sessionId: "chat-session", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Older chat", + startedAt: "2026-03-17T00:00:00.000Z", + transcriptPath: path.join(projectRoot, "chat-session.chat.jsonl"), + toolType: "codex-chat", + }); + + for (let i = 0; i < 505; i++) { + service.create({ + sessionId: `shell-session-${i}`, + laneId: "lane-1", + ptyId: null, + tracked: true, + title: `Shell ${i}`, + startedAt: new Date(Date.UTC(2026, 2, 17, 0, 10, i)).toISOString(), + transcriptPath: `/tmp/shell-session-${i}.log`, + toolType: "shell", + }); + } + + const listed = service.list({ laneId: "lane-1", limit: 10, toolTypes: ["codex-chat"] }); + expect(listed).toHaveLength(1); + expect(listed[0]?.id).toBe("chat-session"); + + activeDisposers.push(async () => db.close()); + }); + it("repairs legacy droid chat rows from their resume command", async () => { const projectRoot = makeProjectRoot("ade-session-service-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); @@ -454,6 +492,18 @@ describe("sessionService resume metadata", () => { const session = service.get("session-legacy"); expect(session?.toolType).toBe("droid-chat"); expect(session?.resumeCommand).toBe("chat:droid:session-legacy"); + service.create({ + sessionId: "session-shell", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Shell", + startedAt: "2026-03-17T00:11:00.000Z", + transcriptPath: path.join(projectRoot, "session-shell.log"), + toolType: "shell", + }); + const listed = service.list({ laneId: "lane-1", toolTypes: ["droid-chat"] }); + expect(listed.map((row) => row.id)).toEqual(["session-legacy"]); activeDisposers.push(async () => db.close()); }); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index b07af87f8..4527a660c 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -287,6 +287,16 @@ export function createSessionService({ db }: { db: AdeDb }) { return toolType; }; + const normalizeToolTypes = (raw: unknown): TerminalToolType[] => { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + for (const value of raw) { + const normalized = normalizeToolType(value); + if (normalized) seen.add(normalized); + } + return Array.from(seen); + }; + const mapRow = (row: SessionRow) => { const toolType = inferToolTypeFromResumeCommand( normalizeToolType(row.toolType), @@ -329,9 +339,10 @@ export function createSessionService({ db }: { db: AdeDb }) { updatedAt: row.updatedAt, }); - const list =({ laneId, status, limit }: ListSessionsArgs = {}) => { + const list =({ laneId, status, limit, toolTypes }: ListSessionsArgs = {}) => { const where: string[] = []; const params: (string | number | null)[] = []; + const effectiveLimit = limit === null ? null : typeof limit === "number" ? limit : 200; if (laneId) { where.push("s.lane_id = ?"); @@ -341,22 +352,74 @@ export function createSessionService({ db }: { db: AdeDb }) { where.push("s.status = ?"); params.push(status); } + const normalizedToolTypes = normalizeToolTypes(toolTypes); + const fetchRows = ( + extraWhere: string[] = [], + extraParams: (string | number | null)[] = [], + ): SessionRow[] => { + const queryWhere = [...where, ...extraWhere]; + const queryParams: (string | number | null)[] = [...params, ...extraParams]; + const whereSql = queryWhere.length ? `where ${queryWhere.join(" and ")}` : ""; + const limitSql = effectiveLimit === null ? "" : "limit ?"; + if (effectiveLimit !== null) queryParams.push(effectiveLimit); + + return db.all( + ` + select ${SESSION_COLUMNS} + from terminal_sessions s + join lanes l on l.id = s.lane_id + ${whereSql} + order by s.started_at desc + ${limitSql} + `, + queryParams + ); + }; - const whereSql = where.length ? `where ${where.join(" and ")}` : ""; - const limitSql = limit === null ? "" : "limit ?"; - if (limit !== null) params.push(typeof limit === "number" ? limit : 200); - - const rows = db.all( - ` - select ${SESSION_COLUMNS} - from terminal_sessions s - join lanes l on l.id = s.lane_id - ${whereSql} - order by s.started_at desc - ${limitSql} - `, - params - ); + if (normalizedToolTypes.length > 0) { + const legacyChatClauses: string[] = []; + const legacyChatParams: (string | number | null)[] = []; + + for (const toolType of normalizedToolTypes) { + if (toolType === "codex-chat") { + legacyChatClauses.push( + "(lower(coalesce(s.resume_command, '')) = ? or lower(coalesce(s.resume_command, '')) like ?)", + ); + legacyChatParams.push("chat:codex", "chat:codex:%"); + } else if (toolType === "claude-chat") { + legacyChatClauses.push("lower(coalesce(s.resume_command, '')) like ?"); + legacyChatParams.push("chat:claude:%"); + } else if (toolType === "opencode-chat") { + legacyChatClauses.push("lower(coalesce(s.resume_command, '')) like ?"); + legacyChatParams.push("chat:unified:%"); + } else if (toolType === "cursor") { + legacyChatClauses.push("lower(coalesce(s.resume_command, '')) like ?"); + legacyChatParams.push("chat:cursor:%"); + } else if (toolType === "droid-chat") { + legacyChatClauses.push("lower(coalesce(s.resume_command, '')) like ?"); + legacyChatParams.push("chat:droid:%"); + } + } + + const rowsById = new Map(); + for (const toolType of normalizedToolTypes) { + for (const row of fetchRows(["s.tool_type = ?"], [toolType])) { + rowsById.set(row.id, row); + } + } + if (legacyChatClauses.length > 0) { + for (const row of fetchRows(["s.tool_type = 'other'", `(${legacyChatClauses.join(" or ")})`], legacyChatParams)) { + rowsById.set(row.id, row); + } + } + + const rows = Array.from(rowsById.values()) + .sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt)); + const limitedRows = effectiveLimit === null ? rows : rows.slice(0, effectiveLimit); + return limitedRows.map(mapRow) as TerminalSessionSummary[]; + } + + const rows = fetchRows(); return rows.map(mapRow) as TerminalSessionSummary[]; }; diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index c5a0bc485..34f55e043 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -1503,6 +1503,8 @@ function migrate(db: MigrationDb) { db.run("create index if not exists idx_terminal_sessions_status on terminal_sessions(status)"); db.run("create index if not exists idx_terminal_sessions_started_at on terminal_sessions(started_at desc)"); db.run("create index if not exists idx_terminal_sessions_lane_started_at on terminal_sessions(lane_id, started_at desc)"); + db.run("create index if not exists idx_terminal_sessions_lane_tool_started on terminal_sessions(lane_id, tool_type, started_at desc)"); + db.run("create index if not exists idx_terminal_sessions_tool_started on terminal_sessions(tool_type, started_at desc)"); // Migration: add resume_command to existing databases that pre-date this column. safeAddColumn(db, "alter table terminal_sessions add column resume_command text"); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 8d98cff34..aeb1bd77f 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -12,6 +12,7 @@ import type { AdoptAttachedLaneArgs, UnregisteredLaneCandidate, AppInfo, + AppResourceUsageSnapshot, LatestReleaseInfo, AppNavigationRequest, AutoUpdateSnapshot, @@ -670,6 +671,7 @@ declare global { app: { ping: () => Promise<"pong">; getInfo: () => Promise; + getResourceUsage: () => Promise; getLatestRelease: () => Promise; getProject: () => Promise; getWindowSession: () => Promise<{ diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index fffa99e67..3797d6d41 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -17,6 +17,7 @@ import type { AdoptAttachedLaneArgs, UnregisteredLaneCandidate, AppInfo, + AppResourceUsageSnapshot, LatestReleaseInfo, AppNavigationRequest, AutoUpdateSnapshot, @@ -2952,6 +2953,8 @@ contextBridge.exposeInMainWorld("ade", { app: { ping: async (): Promise<"pong"> => ipcRenderer.invoke(IPC.appPing), getInfo: async (): Promise => ipcRenderer.invoke(IPC.appGetInfo), + getResourceUsage: async (): Promise => + ipcRenderer.invoke(IPC.appGetResourceUsage), getLatestRelease: async (): Promise => ipcRenderer.invoke(IPC.appGetLatestRelease), getProject: async (): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 6abcd74cd..18702fb7a 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3184,6 +3184,22 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, }, }), + getResourceUsage: resolved({ + sampledAt: now, + processCount: 1, + cpuPercent: 0, + mainCpuPercent: 0, + rendererCpuPercent: 0, + memoryMB: 0, + mainMemoryMB: 0, + rendererMemoryMB: 0, + activePtyCount: 0, + ptyProcessCount: 0, + ptyCpuPercent: 0, + ptyMemoryMB: 0, + freeMemoryMB: null, + totalMemoryMB: null, + }), getLatestRelease: resolved({ version: "1.0.0", htmlUrl: "https://github.com/arul28/ADE/releases/latest", diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index e3cdda16a..932cf8074 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -157,11 +157,30 @@ async function advancePhoneSyncStartupDelay() { }); } +const resourceUsageMock = vi.fn(); + describe("TopBar", () => { const originalAde = globalThis.window.ade; beforeEach(() => { resetStore(); + resourceUsageMock.mockReset(); + resourceUsageMock.mockResolvedValue({ + sampledAt: "2026-04-22T00:00:00.000Z", + processCount: 2, + cpuPercent: 1, + mainCpuPercent: 0.5, + rendererCpuPercent: 0.5, + memoryMB: 240, + mainMemoryMB: 80, + rendererMemoryMB: 160, + activePtyCount: 0, + ptyProcessCount: 0, + ptyCpuPercent: 0, + ptyMemoryMB: 0, + freeMemoryMB: 12_000, + totalMemoryMB: 16_000, + }); globalThis.window.ade = { app: { getWindowSession: vi.fn(async () => ({ windowId: 1, project: useAppStore.getState().project, openProjectTabs: [] })), @@ -172,6 +191,7 @@ describe("TopBar", () => { project: { rootPath, name: rootPath.split("/").pop() ?? rootPath }, })), closeWindow: vi.fn(async () => ({ closed: true })), + getResourceUsage: resourceUsageMock, }, project: { listRecent: vi.fn(async () => [ @@ -338,6 +358,34 @@ describe("TopBar", () => { expect(globalThis.window.ade.github.getStatus).not.toHaveBeenCalled(); }); + it("shows a header warning when ADE-spawned terminals create resource pressure", async () => { + useAppStore.setState({ projectHydrated: true, showWelcome: false } as any); + resourceUsageMock.mockResolvedValue({ + sampledAt: "2026-04-22T00:00:00.000Z", + processCount: 24, + cpuPercent: 92, + mainCpuPercent: 8, + rendererCpuPercent: 14, + memoryMB: 5_800, + mainMemoryMB: 320, + rendererMemoryMB: 640, + activePtyCount: 12, + ptyProcessCount: 19, + ptyCpuPercent: 91, + ptyMemoryMB: 4_900, + freeMemoryMB: 900, + totalMemoryMB: 16_000, + }); + + render(); + + const indicator = await screen.findByLabelText("ADE resource pressure level 4"); + expect(indicator.getAttribute("data-ade-resource-pressure-active-ptys")).toBe("12"); + expect(indicator.getAttribute("data-ade-resource-pressure-pty-cpu")).toBe("91"); + expect(indicator.getAttribute("title")).toContain("Background live refreshes are slowed"); + expect(indicator.getAttribute("title")).toContain("selected chats and terminals stay full speed"); + }); + it("consolidates a cross-window project tab dropped onto the same project", async () => { render(); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 8762f833c..077b61a33 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -19,6 +19,7 @@ import { Plugs, Trash, UploadSimple, + WarningCircle, X, } from "@phosphor-icons/react"; import * as Dialog from "@radix-ui/react-dialog"; @@ -42,6 +43,7 @@ import type { RecentProjectSummary, RemoteRuntimeConnectionSnapshot, SyncRoleSnapshot, + AppResourceUsageSnapshot, } from "../../../shared/types"; import { AutoUpdateControl } from "./AutoUpdateControl"; import { FeedbackReporterModal } from "./FeedbackReporterModal"; @@ -51,6 +53,7 @@ import { PublishToGitHubDialog } from "../projects/PublishToGitHubDialog"; import { RemoteTargetList } from "../remoteTargets/RemoteTargetList"; import { SyncDevicesSection } from "../settings/SyncDevicesSection"; import { HeaderUsageControl } from "../usage/HeaderUsageControl"; +import { appResourcePressureLevel, getAppResourceUsageCoalesced, resourcePressureDescription } from "../../lib/resourcePressure"; const RUNNING_LANE_PROCESS_STATES: ProcessRuntime["status"][] = [ "starting", @@ -69,6 +72,7 @@ const PROJECT_ICON_ACCENT_CACHE_MAX = 48; const projectIconAccentCache = new Map(); const RECENT_PROJECTS_CACHE_TTL_MS = 2_500; const PHONE_SYNC_STARTUP_DELAY_MS = 5_000; +const RESOURCE_PRESSURE_SAMPLE_MS = 2_000; let recentProjectsCache: | { rows: RecentProjectSummary[]; fetchedAtMs: number } | null = null; @@ -288,6 +292,86 @@ function useHeaderStatusCompactLayout(): boolean { return compact; } +function useResourcePressureUsage(enabled: boolean): AppResourceUsageSnapshot | null { + const [usage, setUsage] = useState(null); + + useEffect(() => { + if (!enabled) { + setUsage(null); + return; + } + + let cancelled = false; + let requestVersion = 0; + const refresh = () => { + if (document.visibilityState !== "visible") return; + const version = ++requestVersion; + void getAppResourceUsageCoalesced() + .then((snapshot) => { + if (!cancelled && version === requestVersion) setUsage(snapshot); + }) + }; + + refresh(); + const interval = window.setInterval(refresh, RESOURCE_PRESSURE_SAMPLE_MS); + const refreshWhenVisible = () => { + if (document.visibilityState === "visible") refresh(); + }; + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", refreshWhenVisible); + return () => { + cancelled = true; + window.clearInterval(interval); + window.removeEventListener("focus", refresh); + document.removeEventListener("visibilitychange", refreshWhenVisible); + }; + }, [enabled]); + + return usage; +} + +function ResourcePressureIndicator({ usage }: { usage: AppResourceUsageSnapshot | null }) { + const level = appResourcePressureLevel(usage); + if (level === 0) return null; + const color = + level >= 4 ? "#F87171" : level === 3 ? "#FB7185" : level === 2 ? "#FB923C" : "#FBBF24"; + const description = resourcePressureDescription(usage); + return ( + + + + + + ); +} + const HEADER_STATUS_MENU_ROW_CLASS = "flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-[11px] font-medium text-muted-fg/80 transition-colors duration-150 hover:bg-white/[0.06] hover:text-fg/90"; @@ -847,6 +931,7 @@ export function TopBar() { isNewTabOpen !== true && Boolean(project?.rootPath) && !remoteBinding; + const resourceUsage = useResourcePressureUsage(workspaceProjectOpen); const projectRootForRemote = workspaceProjectOpen ? (project?.rootPath ?? null) @@ -2102,6 +2187,8 @@ export function TopBar() { {/* Trailing controls: status · updates · utility cluster */}
+ +
{renderHeaderStatusControls()}
diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index e2b030248..2230e00f5 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -756,9 +756,8 @@ function GraphInner({ active = true }: { active?: boolean }) { return; } const includeOperations = options?.includeOperations ?? true; - const projectRoot = project?.rootPath ?? null; const [sessions, operations] = await Promise.all([ - listSessionsCached({ limit: GRAPH_ACTIVITY_SESSION_LIMIT }, { projectRoot }).then((rows) => + listSessionsCached({ limit: GRAPH_ACTIVITY_SESSION_LIMIT }).then((rows) => rows.filter((session) => !isRunOwnedSession(session)), ), includeOperations diff --git a/apps/desktop/src/renderer/components/lanes/CommitTimeline.test.tsx b/apps/desktop/src/renderer/components/lanes/CommitTimeline.test.tsx index 969cbd80a..c2f482449 100644 --- a/apps/desktop/src/renderer/components/lanes/CommitTimeline.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/CommitTimeline.test.tsx @@ -35,4 +35,52 @@ describe("CommitTimeline", () => { }); expect(screen.getByText(/Restore or recreate the lane worktree at \/tmp\/missing before viewing history\./)).toBeTruthy(); }); + + it("does not load commits while inactive", () => { + const listRecentCommits = vi.fn(async () => []); + (window as any).ade = { + git: { listRecentCommits }, + }; + + render( + , + ); + + expect(listRecentCommits).not.toHaveBeenCalled(); + }); + + it("loads commits when an inactive timeline becomes active", async () => { + const listRecentCommits = vi.fn(async () => []); + (window as any).ade = { + git: { listRecentCommits }, + }; + + const view = render( + , + ); + expect(listRecentCommits).not.toHaveBeenCalled(); + + view.rerender( + , + ); + + await waitFor(() => { + expect(listRecentCommits).toHaveBeenCalledWith({ laneId: "lane-1", limit: 40 }); + }); + }); }); diff --git a/apps/desktop/src/renderer/components/lanes/CommitTimeline.tsx b/apps/desktop/src/renderer/components/lanes/CommitTimeline.tsx index c07de93c0..abb511743 100644 --- a/apps/desktop/src/renderer/components/lanes/CommitTimeline.tsx +++ b/apps/desktop/src/renderer/components/lanes/CommitTimeline.tsx @@ -42,6 +42,7 @@ type CommitMeta = { export function CommitTimeline({ laneId, + active = true, selectedSha, onSelectCommit, refreshTrigger, @@ -49,6 +50,7 @@ export function CommitTimeline({ remoteMissing }: { laneId: string | null; + active?: boolean; selectedSha: string | null; onSelectCommit: (commit: GitCommitSummary) => void; refreshTrigger?: number; @@ -68,7 +70,7 @@ export function CommitTimeline({ const didInitialScrollRef = React.useRef(false); const load = React.useCallback(async () => { - if (!laneId) return; + if (!active || !laneId) return; setLoading(true); setError(null); try { @@ -80,7 +82,7 @@ export function CommitTimeline({ } finally { setLoading(false); } - }, [laneId, limit]); + }, [active, laneId, limit]); React.useEffect(() => { metaByShaRef.current = new Map(); diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx index 2484833d4..e5c4a08c9 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx @@ -8,8 +8,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DiffChanges, GitConflictState, GitStashSummary, GitUpstreamSyncStatus, LaneSummary } from "../../../shared/types"; import { __resetLaneGitActionRuntimeForTests, LaneGitActionsPane } from "./LaneGitActionsPane"; +const commitTimelineMock = vi.hoisted(() => vi.fn((props: Record) => null)); + vi.mock("./CommitTimeline", () => ({ - CommitTimeline: () => null, + CommitTimeline: (props: Record) => commitTimelineMock(props), })); vi.mock("./LaneDiffPane", () => ({ @@ -148,6 +150,7 @@ describe("LaneGitActionsPane rescue action", () => { }; mockAutoRebaseStatuses = []; failDiffRefresh = false; + commitTimelineMock.mockClear(); globalThis.window.ade = { diff: { @@ -205,7 +208,7 @@ describe("LaneGitActionsPane rescue action", () => { }); function renderPane(overrides?: Partial>) { - render( + return render( { ); } + it("does not start Git Actions effects while inactive", async () => { + renderPane({ active: false }); + + expect(window.ade.diff.getChanges).not.toHaveBeenCalled(); + expect(window.ade.git.stashList).not.toHaveBeenCalled(); + expect(window.ade.git.getSyncStatus).not.toHaveBeenCalled(); + expect(window.ade.git.getConflictState).not.toHaveBeenCalled(); + expect(window.ade.lanes.onAutoRebaseEvent).not.toHaveBeenCalled(); + expect(commitTimelineMock).toHaveBeenCalledWith(expect.objectContaining({ active: false })); + }); + + it("loads Git Actions once when an inactive pane becomes active", async () => { + const view = renderPane({ active: false }); + expect(window.ade.diff.getChanges).not.toHaveBeenCalled(); + + view.rerender( + + + , + ); + + await screen.findByRole("button", { name: "SYNC" }); + + expect(window.ade.diff.getChanges).toHaveBeenCalledTimes(1); + expect(window.ade.git.stashList).toHaveBeenCalledTimes(1); + expect(window.ade.git.getSyncStatus).toHaveBeenCalledTimes(1); + expect(window.ade.git.getConflictState).toHaveBeenCalledTimes(1); + expect(window.ade.lanes.onAutoRebaseEvent).toHaveBeenCalledTimes(1); + expect(commitTimelineMock).toHaveBeenLastCalledWith(expect.objectContaining({ active: true })); + }); + it("does not refresh the global lane store on initial mount", async () => { renderPane(); diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index cb094ed3b..38cf9756d 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -562,6 +562,7 @@ function ActionButton({ export function LaneGitActionsPane({ laneId, + active = true, autoRebaseEnabled, autoRebaseStatusSnapshot, onOpenSettings, @@ -578,6 +579,7 @@ export function LaneGitActionsPane({ selectedCommitSha }: { laneId: string | null; + active?: boolean; autoRebaseEnabled: boolean; autoRebaseStatusSnapshot?: AutoRebaseLaneStatus | null; onOpenSettings: () => void; @@ -988,7 +990,7 @@ export function LaneGitActionsPane({ setAutoRebaseStatus(autoRebaseStatusSnapshotRef.current ?? cached?.autoRebaseStatus ?? null); setConflictState(cached?.conflictState ?? null); setStuckRebase(cached?.stuckRebase ?? null); - if (!laneId) return; + if (!active || !laneId) return; Promise.all([refreshChanges(laneId), refreshGitMeta(laneId)]).catch((err) => { patchLaneGitActionRuntimeState(laneId, { notice: null, @@ -996,10 +998,10 @@ export function LaneGitActionsPane({ }); }); void refreshCommitMessageAiState(); - }, [laneId, lane?.branchRef, projectRoot, refreshCommitMessageAiState]); + }, [active, laneId, lane?.branchRef, projectRoot, refreshCommitMessageAiState]); useEffect(() => { - if (!laneId) return; + if (!active || !laneId) return; if (autoRebaseStatusSnapshotRef.current !== undefined) return; const targetLaneId = laneId; const timer = window.setTimeout(() => { @@ -1008,10 +1010,10 @@ export function LaneGitActionsPane({ void refreshAutoRebaseStatus(targetLaneId); }, 3_500); return () => window.clearTimeout(timer); - }, [laneId, lane?.branchRef, refreshAutoRebaseStatus]); + }, [active, laneId, lane?.branchRef, refreshAutoRebaseStatus]); useEffect(() => { - if (!laneId) return; + if (!active || !laneId) return; let refreshTimer: number | null = null; const effectLaneId = laneId; const refreshSyncStatus = () => { @@ -1053,9 +1055,10 @@ export function LaneGitActionsPane({ window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, [isViewingLane, laneId, projectRoot]); + }, [active, isViewingLane, laneId, projectRoot]); useEffect(() => { + if (!active) return; const unsubscribe = window.ade.lanes.onAutoRebaseEvent((event) => { if (event.type !== "auto-rebase-updated") return; if (!laneId) { @@ -1067,7 +1070,7 @@ export function LaneGitActionsPane({ setAutoRebaseStatus(nextStatus); }); return unsubscribe; - }, [laneId, projectRoot]); + }, [active, laneId, projectRoot]); const changedFileCount = useMemo(() => { const paths = new Set(); @@ -2837,6 +2840,7 @@ export function LaneGitActionsPane({
{ }); }); +describe("getDeferredLanePaneDelayMs", () => { + it("keeps the first visible lane eager and staggers later lanes", () => { + const visibleLaneIds = ["lane-a", "lane-b", "lane-c"]; + + expect(getDeferredLanePaneDelayMs({ laneId: "lane-a", visibleLaneIds, stepMs: 100 })).toBe(0); + expect(getDeferredLanePaneDelayMs({ laneId: "lane-b", visibleLaneIds, stepMs: 100 })).toBe(100); + expect(getDeferredLanePaneDelayMs({ laneId: "lane-c", visibleLaneIds, stepMs: 100 })).toBe(200); + }); + + it("does not defer unknown lanes and caps long queues", () => { + expect(getDeferredLanePaneDelayMs({ + laneId: "lane-z", + visibleLaneIds: ["lane-a", "lane-b"], + stepMs: 100, + maxMs: 150, + })).toBe(0); + expect(getDeferredLanePaneDelayMs({ + laneId: "lane-d", + visibleLaneIds: ["lane-a", "lane-b", "lane-c", "lane-d"], + stepMs: 100, + maxMs: 150, + })).toBe(150); + }); +}); + describe("buildLaneSplitColumnsKey", () => { it("does not depend on the current split lane ids", () => { const beforeDelete = buildLaneSplitColumnsKey({ @@ -609,6 +636,68 @@ describe("selectLaneTabPrTag", () => { }); }); +describe("selectVisibleLanePrRefreshIds", () => { + it("selects stale linked PRs for visible lanes in visible order", () => { + const lanePrByLaneId = new Map([ + ["lane-2", { ...selectLaneTabPrTag(makeLane({ id: "lane-2", branchRef: "ade/two" }), [makePr({ id: "pr-2", laneId: "lane-2", headBranch: "ade/two" })], [])!, linkedPrId: "pr-2" }], + ["lane-1", { ...selectLaneTabPrTag(makeLane(), [makePr()], [])!, linkedPrId: "pr-1" }], + ]); + + expect(selectVisibleLanePrRefreshIds({ + visibleLaneIds: ["lane-1", "lane-2"], + lanePrByLaneId, + prs: [ + makePr({ id: "pr-1", lastSyncedAt: "2026-05-01T00:00:00.000Z" }), + makePr({ id: "pr-2", laneId: "lane-2", headBranch: "ade/two", lastSyncedAt: "2026-05-01T00:00:01.000Z" }), + ], + nowMs: Date.parse("2026-05-01T00:01:00.000Z"), + limit: 4, + })).toEqual(["pr-1", "pr-2"]); + }); + + it("skips fresh, recently requested, and GitHub-only PR tags", () => { + const nowMs = Date.parse("2026-05-01T00:01:00.000Z"); + const lanePrByLaneId = new Map([ + ["fresh", { ...selectLaneTabPrTag(makeLane({ id: "fresh", branchRef: "ade/fresh" }), [makePr({ id: "fresh-pr", laneId: "fresh", headBranch: "ade/fresh" })], [])!, linkedPrId: "fresh-pr" }], + ["recent", { ...selectLaneTabPrTag(makeLane({ id: "recent", branchRef: "ade/recent" }), [makePr({ id: "recent-pr", laneId: "recent", headBranch: "ade/recent" })], [])!, linkedPrId: "recent-pr" }], + ["github", { ...selectLaneTabPrTag(makeLane({ id: "github", branchRef: "ade/github" }), [], [makeGitHubPr({ headBranch: "ade/github", linkedPrId: null })])!, linkedPrId: null }], + ["stale", { ...selectLaneTabPrTag(makeLane({ id: "stale", branchRef: "ade/stale" }), [makePr({ id: "stale-pr", laneId: "stale", headBranch: "ade/stale" })], [])!, linkedPrId: "stale-pr" }], + ]); + + expect(selectVisibleLanePrRefreshIds({ + visibleLaneIds: ["fresh", "recent", "github", "stale"], + lanePrByLaneId, + prs: [ + makePr({ id: "fresh-pr", laneId: "fresh", headBranch: "ade/fresh", lastSyncedAt: "2026-05-01T00:00:55.000Z" }), + makePr({ id: "recent-pr", laneId: "recent", headBranch: "ade/recent", lastSyncedAt: "2026-05-01T00:00:00.000Z" }), + makePr({ id: "stale-pr", laneId: "stale", headBranch: "ade/stale", lastSyncedAt: null }), + ], + recentlyRequestedAtByPrId: new Map([["recent-pr", nowMs - 5_000]]), + nowMs, + staleMs: 15_000, + limit: 4, + })).toEqual(["stale-pr"]); + }); + + it("dedupes and caps linked PR refreshes", () => { + const lanePrByLaneId = new Map([ + ["lane-1", { ...selectLaneTabPrTag(makeLane(), [makePr({ id: "shared-pr" })], [])!, linkedPrId: "shared-pr" }], + ["lane-2", { ...selectLaneTabPrTag(makeLane({ id: "lane-2", branchRef: "ade/two" }), [makePr({ id: "shared-pr", laneId: "lane-2", headBranch: "ade/two" })], [])!, linkedPrId: "shared-pr" }], + ["lane-3", { ...selectLaneTabPrTag(makeLane({ id: "lane-3", branchRef: "ade/three" }), [makePr({ id: "pr-3", laneId: "lane-3", headBranch: "ade/three" })], [])!, linkedPrId: "pr-3" }], + ]); + + expect(selectVisibleLanePrRefreshIds({ + visibleLaneIds: ["lane-1", "lane-2", "lane-3"], + lanePrByLaneId, + prs: [ + makePr({ id: "shared-pr", lastSyncedAt: null }), + makePr({ id: "pr-3", laneId: "lane-3", headBranch: "ade/three", lastSyncedAt: null }), + ], + limit: 1, + })).toEqual(["shared-pr"]); + }); +}); + describe("sortLaneListRows", () => { it("keeps primary first and promotes pinned lanes before runtime buckets", () => { const lanes = [ diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index c963b36db..545a9fb67 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -39,6 +39,7 @@ import { useOnboardingStore } from "../../state/onboardingStore"; import { useDialogBus } from "../../lib/useDialogBus"; import { buildLaneActionClearedSearch, + getDeferredLanePaneDelayMs, parseLaneIdsParam, laneHasAncestor, planLaneDeleteBatches, @@ -48,6 +49,7 @@ import { resolveVisibleLaneIds, runLaneDeleteBatchWithConcurrency, selectLanePrTag, + selectVisibleLanePrRefreshIds, selectLaneTabPrTag, shouldApplyLaneIdsDeepLink, sortLaneListRows, @@ -75,6 +77,7 @@ import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { getGitHubSnapshotCoalesced, listPrsCoalesced, refreshPrsCoalesced, warmPrSurfaceCoalesced } from "../../lib/prReadCache"; import { logRendererDebugEvent } from "../../lib/debugLog"; +import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; import { linearIssueBranchName, linearIssueLaneName } from "../../../shared/linearIssueBranch"; import type { BranchPullRequest, @@ -141,6 +144,10 @@ type RebasePushReviewState = { const ADOPT_HINT_DISMISSED_KEY = "ade.lanes.adoptHintDismissed.v1"; const LANE_DELETE_REFRESH_DEBOUNCE_MS = 160; +const LANE_VISIBLE_PR_REFRESH_DEBOUNCE_MS = 260; +const LANE_RUNTIME_LIFECYCLE_REFRESH_DEBOUNCE_MS = 300; +const LANE_RUNTIME_DATA_REFRESH_DEBOUNCE_MS = 5_000; +const EMPTY_LANE_IDS: string[] = []; function normalizeLaneRuntimePlacement(value: unknown): LaneRuntimePlacement { return value === "macos-vm" ? "macos-vm" : "local"; @@ -180,13 +187,35 @@ function getDevicePresenceTitle(devicesOpen: LaneSummary["devicesOpen"]): string } function DeferredLanePane({ + cacheKey, children, + delayMs = 0, }: { cacheKey: string; label: string; children: React.ReactNode; + delayMs?: number; }) { - return <>{children}; + const [ready, setReady] = useState(delayMs <= 0); + const initializedCacheKeysRef = useRef>(new Set()); + + useEffect(() => { + const initializedCacheKeys = initializedCacheKeysRef.current; + if (initializedCacheKeys.has(cacheKey)) { + setReady(true); + return; + } + initializedCacheKeys.add(cacheKey); + if (delayMs <= 0) { + setReady(true); + return; + } + setReady(false); + const timer = window.setTimeout(() => setReady(true), delayMs); + return () => window.clearTimeout(timer); + }, [cacheKey, delayMs]); + + return ready ? <>{children} : null; } function lanePrTagColor(state: PrSummary["state"]): string { @@ -196,6 +225,20 @@ function lanePrTagColor(state: PrSummary["state"]): string { return COLORS.accent; } +function mergePrSummariesById(current: PrSummary[], refreshed: PrSummary[]): PrSummary[] { + if (refreshed.length === 0) return current; + const refreshedById = new Map(refreshed.map((pr) => [pr.id, pr] as const)); + const seen = new Set(); + const next = current.map((pr) => { + seen.add(pr.id); + return refreshedById.get(pr.id) ?? pr; + }); + for (const pr of refreshed) { + if (!seen.has(pr.id)) next.push(pr); + } + return next; +} + function isTrustedGitHubUrl(rawUrl: string): boolean { try { const url = new URL(rawUrl); @@ -503,6 +546,9 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const [managedLaneIds, setManagedLaneIds] = useState([]); const lanePrTagsRequestRef = useRef(0); const laneGithubPrTagsRequestRef = useRef(0); + const laneVisiblePrRefreshRequestedAtRef = useRef>(new Map()); + const laneVisiblePrRefreshProjectRootRef = useRef(null); + const [laneVisiblePrRefreshVisibilityToken, setLaneVisiblePrRefreshVisibilityToken] = useState(0); const hasActiveLaneRuntimeRef = useRef(false); const [autoRebaseEnabled, setAutoRebaseEnabled] = useState(false); const [rebaseSuggestionError, setRebaseSuggestionError] = useState(null); @@ -702,8 +748,9 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const stackGraphLanes = useMemo(() => sortLanesForStackGraph(filteredLanes), [filteredLanes]); const filteredLaneIds = useMemo(() => filteredLanes.map((lane) => lane.id), [filteredLanes]); - // Per-lane agent rosters (ADE chat + CLI agents) for the inline dashboards. - const agentsByLaneId = useLaneAgents(filteredLaneIds); + const stackGraphAgentLaneIds = stackGraphHeaderOpen ? filteredLaneIds : EMPTY_LANE_IDS; + // Per-lane agent rosters are only shown inside Stack Graph; keep the closed route cheap. + const agentsByLaneId = useLaneAgents(stackGraphAgentLaneIds); const selectableFilteredLaneIds = useMemo( () => filteredLaneIds.filter((laneId) => !deletingLaneIds.has(laneId)), [filteredLaneIds, deletingLaneIds], @@ -1107,9 +1154,66 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { }); }, [active, refreshLanePrTags, refreshLaneGithubPrTags]); + useEffect(() => { + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + setLaneVisiblePrRefreshVisibilityToken((value) => value + 1); + } + }; + document.addEventListener("visibilitychange", onVisibilityChange); + return () => document.removeEventListener("visibilitychange", onVisibilityChange); + }, []); + + useEffect(() => { + const projectRoot = project?.rootPath ?? null; + if (laneVisiblePrRefreshProjectRootRef.current !== projectRoot) { + laneVisiblePrRefreshProjectRootRef.current = projectRoot; + laneVisiblePrRefreshRequestedAtRef.current.clear(); + } + if (!active || !projectRoot || document.visibilityState !== "visible") return; + + const nowMs = Date.now(); + const prIds = selectVisibleLanePrRefreshIds({ + visibleLaneIds, + lanePrByLaneId, + prs: lanePrTags, + recentlyRequestedAtByPrId: laneVisiblePrRefreshRequestedAtRef.current, + nowMs, + }); + if (prIds.length === 0) return; + + for (const prId of prIds) { + laneVisiblePrRefreshRequestedAtRef.current.set(prId, nowMs); + } + + const startedRoot = projectRoot; + const timer = window.setTimeout(() => { + void window.ade.prs.refresh({ prIds }) + .then((refreshed) => { + if ((appStore.getState().project?.rootPath ?? null) !== startedRoot) return; + if (refreshed.length === 0) return; + setLanePrTags((current) => mergePrSummariesById(current, refreshed)); + }) + .catch(() => { + // Background PR refresh is opportunistic; the normal PR poller remains the fallback. + }); + }, LANE_VISIBLE_PR_REFRESH_DEBOUNCE_MS); + + return () => window.clearTimeout(timer); + }, [ + active, + appStore, + project?.rootPath, + visibleLaneIds, + lanePrByLaneId, + lanePrTags, + laneVisiblePrRefreshVisibilityToken, + ]); + useEffect(() => { if (!active) return; - let timer: ReturnType | null = null; + let lifecycleTimer: ReturnType | null = null; + let dataTimer: ReturnType | null = null; const refreshRuntimeOnly = () => refreshLanes({ includeStatus: false, @@ -1118,31 +1222,43 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { includeRebaseSuggestions: false, includeAutoRebaseStatus: false, }); - const scheduleRefresh = () => { + const scheduleRefresh = (kind: "lifecycle" | "data") => { if (document.visibilityState !== "visible") return; - if (timer) return; // already scheduled - timer = setTimeout(() => { - timer = null; + const delayMs = + kind === "data" + ? LANE_RUNTIME_DATA_REFRESH_DEBOUNCE_MS + : LANE_RUNTIME_LIFECYCLE_REFRESH_DEBOUNCE_MS; + const getTimer = () => (kind === "data" ? dataTimer : lifecycleTimer); + const setTimer = (timer: ReturnType | null) => { + if (kind === "data") dataTimer = timer; + else lifecycleTimer = timer; + }; + if (getTimer()) return; // already scheduled + setTimer(setTimeout(() => { + setTimer(null); void refreshRuntimeOnly().catch(() => {}); - }, 300); + }, delayMs)); }; const currentProjectRoot = project?.rootPath ?? null; const isCurrentProjectEvent = (event: { projectRoot?: string | null }) => !event.projectRoot || event.projectRoot === currentProjectRoot; const unsubPtyData = window.ade.pty.onData((event) => { - if (isCurrentProjectEvent(event)) scheduleRefresh(); + if (isCurrentProjectEvent(event)) scheduleRefresh("data"); }); const unsubPtyExit = window.ade.pty.onExit((event) => { - if (isCurrentProjectEvent(event)) scheduleRefresh(); + if (isCurrentProjectEvent(event)) scheduleRefresh("lifecycle"); + }); + const unsubChat = window.ade.agentChat.onEvent((event) => { + if (shouldRefreshSessionListForChatEvent(event)) scheduleRefresh("lifecycle"); }); - const unsubChat = window.ade.agentChat.onEvent(scheduleRefresh); const intervalId = window.setInterval(() => { if (document.visibilityState !== "visible") return; if (!hasActiveLaneRuntimeRef.current) return; void refreshRuntimeOnly().catch(() => {}); }, 15_000); return () => { - if (timer) clearTimeout(timer); + if (lifecycleTimer) clearTimeout(lifecycleTimer); + if (dataTimer) clearTimeout(dataTimer); try { unsubPtyData(); } catch { @@ -2956,6 +3072,9 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { expandedGitActionsLaneId, surface, }); + const gitActionsDelayMs = surface === "inline" + ? getDeferredLanePaneDelayMs({ laneId, visibleLaneIds }) + : 0; return { "git-actions": { title: "Git Actions", @@ -2984,10 +3103,12 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { ), bodyClassName: "overflow-hidden", - children: mountGitActionsPane ? ( - + children: null, + renderChildren: ({ minimized }: { minimized: boolean }) => mountGitActionsPane ? ( + - { - setLinearIssueChatContextRequest((current) => ( - current?.laneId === pendingLinearIssueContext.laneId - && current.requestedAt === pendingLinearIssueContext.requestedAt - ? null - : current - )); - } - : undefined - } - /> - - ) + children: null, + renderChildren: ({ minimized }: { minimized: boolean }) => { + const mountWorkPane = !minimized && !(surface === "inline" && laneId != null && expandedLaneId === laneId); + return mountWorkPane ? ( + + { + setLinearIssueChatContextRequest((current) => ( + current?.laneId === pendingLinearIssueContext.laneId + && current.requestedAt === pendingLinearIssueContext.requestedAt + ? null + : current + )); + } + : undefined + } + /> + + ) : null; + }, }, }; }, [ @@ -3039,6 +3164,8 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { laneSnapshotByLaneId, linearIssueChatContextRequest, expandedGitActionsLaneId, + expandedLaneId, + visibleLaneIds, autoRebaseEnabled, openAutoRebaseSettings, runRebaseFlow, diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.ts index 2734a8d95..45e84677a 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.ts @@ -158,7 +158,7 @@ export function useLaneAgents(laneIds: string[]): Map { } const [chat, cli] = await Promise.all([ window.ade.agentChat.list({}).catch(() => []), - listSessionsCached({ limit: 500 }, { projectRoot }).catch(() => []), + listSessionsCached({ limit: 500 }).catch(() => []), ]); const requestedLaneIds = new Set(ids); const chatByLane = new Map(); @@ -184,11 +184,13 @@ export function useLaneAgents(laneIds: string[]): Map { } finally { refreshInFlightRef.current = false; } - }, [laneKey, projectRoot]); + }, [laneKey]); useEffect(() => { + // Re-run when the active project changes; listSessionsCached keys by active project. + void projectRoot; void refresh(); - }, [refresh]); + }, [projectRoot, refresh]); useEffect(() => { const scheduleRefresh = () => { diff --git a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts index d22480bd9..f785d4dd5 100644 --- a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts +++ b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts @@ -24,6 +24,11 @@ export type LaneTabPrTag = { state: PrSummary["state"]; }; +export const VISIBLE_LANE_PR_REFRESH_LIMIT = 4; +export const VISIBLE_LANE_PR_REFRESH_STALE_MS = 15_000; +export const DEFERRED_LANE_PANE_STEP_MS = 220; +export const DEFERRED_LANE_PANE_MAX_MS = 3_300; + export function resolveCreateLaneRequest(args: { name: string; createMode: CreateLaneMode; @@ -322,6 +327,61 @@ export function selectLaneTabPrTag( return githubPr ? toLaneTabPrTagFromGithubItem(githubPr, lane.id) : null; } +function isPrRefreshStale(pr: Pick, nowMs: number, staleMs: number): boolean { + const lastSyncedMs = Date.parse(pr.lastSyncedAt ?? ""); + return Number.isNaN(lastSyncedMs) || nowMs - lastSyncedMs >= staleMs; +} + +export function selectVisibleLanePrRefreshIds(args: { + visibleLaneIds: string[]; + lanePrByLaneId: ReadonlyMap; + prs: PrSummary[]; + recentlyRequestedAtByPrId?: ReadonlyMap; + nowMs?: number; + staleMs?: number; + limit?: number; +}): string[] { + const nowMs = args.nowMs ?? Date.now(); + const staleMs = args.staleMs ?? VISIBLE_LANE_PR_REFRESH_STALE_MS; + const limit = args.limit ?? VISIBLE_LANE_PR_REFRESH_LIMIT; + if (limit <= 0) return []; + + const prById = new Map(args.prs.map((pr) => [pr.id, pr] as const)); + const selected: string[] = []; + const seen = new Set(); + + for (const laneId of args.visibleLaneIds) { + const prId = args.lanePrByLaneId.get(laneId)?.linkedPrId; + if (!prId || seen.has(prId)) continue; + seen.add(prId); + + const pr = prById.get(prId); + if (!pr || !isPrRefreshStale(pr, nowMs, staleMs)) continue; + + const requestedAt = args.recentlyRequestedAtByPrId?.get(prId); + if (requestedAt != null && nowMs - requestedAt < staleMs) continue; + + selected.push(prId); + if (selected.length >= limit) break; + } + + return selected; +} + +export function getDeferredLanePaneDelayMs(args: { + laneId: string | null; + visibleLaneIds: readonly string[]; + stepMs?: number; + maxMs?: number; +}): number { + if (!args.laneId) return 0; + const index = args.visibleLaneIds.indexOf(args.laneId); + if (index <= 0) return 0; + const stepMs = args.stepMs ?? DEFERRED_LANE_PANE_STEP_MS; + const maxMs = args.maxMs ?? DEFERRED_LANE_PANE_MAX_MS; + return Math.min(index * stepMs, maxMs); +} + type LaneRuntimeBucket = LaneListSnapshot["runtime"]["bucket"]; export function sortLaneListRows>(args: { diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts index b578f11a9..66da5f10a 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts @@ -78,6 +78,8 @@ vi.mock("../../state/appStore", () => ({ // Import the hook under test (after mocks are declared) // --------------------------------------------------------------------------- import { __clearLaneWorkSessionCacheForTests, useLaneWorkSessions } from "./useLaneWorkSessions"; +import { invalidateSessionListCache } from "../../lib/sessionListCache"; +import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; // --------------------------------------------------------------------------- // window.ade stubs @@ -124,6 +126,13 @@ function makeSession(id: string, laneId: string, title = id) { }; } +function setDocumentVisibility(value: DocumentVisibilityState) { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value, + }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -135,10 +144,13 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { installWindowAde(); // Default: instant resolve for mount-time refresh calls listSessionsCachedMock.mockResolvedValue([]); + vi.mocked(shouldRefreshSessionListForChatEvent).mockReturnValue(false); fakeProjectRoot = "/fake/project"; + setDocumentVisibility("visible"); }); afterEach(() => { + setDocumentVisibility("visible"); delete (window as any).ade; }); @@ -199,6 +211,163 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { expect(second.result.current.sessions.map((session) => session.id)).toEqual(["session-refreshed"]); }); + it("defers hidden session-list changes and refreshes the lane work pane on reveal", async () => { + let onChangedHandler: (() => void) | null = null; + (window as any).ade.sessions.onChanged.mockImplementation((cb: () => void) => { + onChangedHandler = cb; + return () => { + onChangedHandler = null; + }; + }); + + renderHook(() => useLaneWorkSessions("lane-1")); + + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + listSessionsCachedMock.mockClear(); + vi.mocked(invalidateSessionListCache).mockClear(); + listSessionsCachedMock.mockResolvedValue([makeSession("session-revealed", "lane-1")]); + + setDocumentVisibility("hidden"); + await act(async () => { + onChangedHandler?.(); + await new Promise((r) => setTimeout(r, 120)); + }); + + expect(invalidateSessionListCache).toHaveBeenCalled(); + expect(listSessionsCachedMock).not.toHaveBeenCalled(); + + setDocumentVisibility("visible"); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + await new Promise((r) => setTimeout(r, 80)); + }); + + expect(listSessionsCachedMock).toHaveBeenCalledWith( + { laneId: "lane-1", limit: 200 }, + { force: false }, + ); + }); + + it("ignores pty exits for sessions outside the current lane", async () => { + let onExitHandler: ((event: any) => void) | null = null; + (window as any).ade.pty.onExit.mockImplementation((cb: (event: any) => void) => { + onExitHandler = cb; + return () => { + onExitHandler = null; + }; + }); + listSessionsCachedMock.mockResolvedValue([makeSession("session-current", "lane-1")]); + + renderHook(() => useLaneWorkSessions("lane-1")); + + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + listSessionsCachedMock.mockClear(); + vi.mocked(invalidateSessionListCache).mockClear(); + + await act(async () => { + onExitHandler?.({ sessionId: "session-other", ptyId: "pty-other", projectRoot: "/fake/project", exitCode: 0 }); + await new Promise((r) => setTimeout(r, 160)); + }); + + expect(invalidateSessionListCache).not.toHaveBeenCalled(); + expect(listSessionsCachedMock).not.toHaveBeenCalled(); + + await act(async () => { + onExitHandler?.({ sessionId: "session-current", ptyId: "pty-current", projectRoot: "/fake/project", exitCode: 0 }); + await new Promise((r) => setTimeout(r, 160)); + }); + + expect(invalidateSessionListCache).toHaveBeenCalledWith({ projectRoot: "/fake/project", laneId: "lane-1" }); + expect(listSessionsCachedMock).toHaveBeenCalledWith( + { laneId: "lane-1", limit: 200 }, + { force: false }, + ); + }); + + it("ignores chat activity provenanced to another lane", async () => { + let chatEventHandler: ((event: any) => void) | null = null; + (window as any).ade.agentChat.onEvent.mockImplementation((cb: (event: any) => void) => { + chatEventHandler = cb; + return () => { + chatEventHandler = null; + }; + }); + vi.mocked(shouldRefreshSessionListForChatEvent).mockReturnValue(true); + + renderHook(() => useLaneWorkSessions("lane-1")); + + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + listSessionsCachedMock.mockClear(); + vi.mocked(invalidateSessionListCache).mockClear(); + + await act(async () => { + chatEventHandler?.({ sessionId: "session-other", event: { type: "done" }, provenance: { laneId: "lane-2" } }); + await new Promise((r) => setTimeout(r, 240)); + }); + + expect(invalidateSessionListCache).not.toHaveBeenCalled(); + expect(listSessionsCachedMock).not.toHaveBeenCalled(); + + await act(async () => { + chatEventHandler?.({ sessionId: "session-current", event: { type: "done" }, provenance: { laneId: "lane-1" } }); + await new Promise((r) => setTimeout(r, 240)); + }); + + expect(invalidateSessionListCache).toHaveBeenCalledWith({ projectRoot: "/fake/project", laneId: "lane-1" }); + expect(listSessionsCachedMock).toHaveBeenCalledWith( + { laneId: "lane-1", limit: 200 }, + { force: false }, + ); + }); + + it("ignores metadata changes for sessions outside the current lane", async () => { + let onChangedHandler: ((event: any) => void) | null = null; + (window as any).ade.sessions.onChanged.mockImplementation((cb: (event: any) => void) => { + onChangedHandler = cb; + return () => { + onChangedHandler = null; + }; + }); + listSessionsCachedMock.mockResolvedValue([makeSession("session-current", "lane-1")]); + + renderHook(() => useLaneWorkSessions("lane-1")); + + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + listSessionsCachedMock.mockClear(); + vi.mocked(invalidateSessionListCache).mockClear(); + + await act(async () => { + onChangedHandler?.({ sessionId: "session-other", reason: "meta-updated" }); + await new Promise((r) => setTimeout(r, 120)); + }); + + expect(invalidateSessionListCache).not.toHaveBeenCalled(); + expect(listSessionsCachedMock).not.toHaveBeenCalled(); + + await act(async () => { + onChangedHandler?.({ sessionId: "session-new", reason: "created" }); + await new Promise((r) => setTimeout(r, 120)); + }); + + expect(invalidateSessionListCache).toHaveBeenCalledWith({ projectRoot: "/fake/project", laneId: "lane-1" }); + expect(listSessionsCachedMock).toHaveBeenCalledWith( + { laneId: "lane-1", limit: 200 }, + { force: false }, + ); + }); + // ----------------------------------------------------------------------- // launchPtySession: focus/open immediately; refresh reconciles in background. // ----------------------------------------------------------------------- @@ -433,18 +602,18 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { }); it("replays a queued refresh against the latest project after switching projects mid-refresh", async () => { - const fetchedProjectRoots: Array = []; + const fetchOptions: Array<{ force?: boolean }> = []; let firstRefreshResolve: ((rows: unknown[]) => void) | null = null; let secondRefreshResolve: ((rows: unknown[]) => void) | null = null; - listSessionsCachedMock.mockImplementation((_args: { laneId: string }, options?: { projectRoot?: string | null }) => { - fetchedProjectRoots.push(options?.projectRoot); - if (fetchedProjectRoots.length === 1) { + listSessionsCachedMock.mockImplementation((_args: { laneId: string }, options?: { force?: boolean }) => { + fetchOptions.push(options ?? {}); + if (fetchOptions.length === 1) { return new Promise((resolve) => { firstRefreshResolve = resolve; }); } - if (fetchedProjectRoots.length === 2) { + if (fetchOptions.length === 2) { return new Promise((resolve) => { secondRefreshResolve = resolve; }); @@ -457,7 +626,7 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); - expect(fetchedProjectRoots).toEqual(["/fake/project"]); + expect(fetchOptions).toEqual([{ force: true }]); fakeProjectRoot = "/other/project"; act(() => { @@ -467,14 +636,14 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); - expect(fetchedProjectRoots).toEqual(["/fake/project"]); + expect(fetchOptions).toEqual([{ force: true }]); await act(async () => { expect(firstRefreshResolve).not.toBeNull(); firstRefreshResolve!([makeSession("session-old", "lane-1")]); await new Promise((r) => setTimeout(r, 0)); }); - expect(fetchedProjectRoots).toEqual(["/fake/project", "/other/project"]); + expect(fetchOptions).toEqual([{ force: true }, { force: true }]); await act(async () => { expect(secondRefreshResolve).not.toBeNull(); diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index 2673ccc83..4f689097b 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -129,12 +129,14 @@ export function useLaneWorkSessions(laneId: string | null) { const refreshInFlightRef = useRef(false); const refreshQueuedRef = useRef(null); const backgroundRefreshTimerRef = useRef(null); + const pendingHiddenSessionRefreshRef = useRef(false); const hasActiveSessionsRef = useRef(false); const hasLoadedOnceRef = useRef(false); const hasFetchedOnceRef = useRef(false); const laneIdRef = useRef(laneId); const projectRootRef = useRef(projectRoot); const scopeKeyRef = useRef(""); + const sessionIdsRef = useRef>(new Set()); const pendingOptimisticSessionsRef = useRef>(new Map()); const currentLane = useMemo( @@ -181,7 +183,6 @@ export function useLaneWorkSessions(laneId: string | null) { const refresh = useCallback( async (options: { showLoading?: boolean; force?: boolean } = {}) => { const targetLaneId = laneIdRef.current; - const targetProjectRoot = projectRootRef.current; if (!targetLaneId) { setSessions([]); hasLoadedOnceRef.current = true; @@ -214,7 +215,7 @@ export function useLaneWorkSessions(laneId: string | null) { const rows = ( await listSessionsCached( { laneId: targetLaneId, limit: 200 }, - { force: Boolean(options.force), projectRoot: targetProjectRoot }, + { force: Boolean(options.force) }, ) ).filter((session) => !isRunOwnedSession(session)); if (scopeKeyRef.current !== requestedScopeKey) return; @@ -273,10 +274,25 @@ export function useLaneWorkSessions(laneId: string | null) { if (backgroundRefreshTimerRef.current != null) return; backgroundRefreshTimerRef.current = window.setTimeout(() => { backgroundRefreshTimerRef.current = null; + if (document.visibilityState !== "visible") { + pendingHiddenSessionRefreshRef.current = true; + return; + } void refresh({ showLoading: false }); }, delayMs); }, [refresh]); + const markSessionListDirtyOrRefresh = useCallback((delayMs: number) => { + const targetLaneId = laneIdRef.current; + if (!targetLaneId) return; + invalidateSessionListCache({ projectRoot: projectRootRef.current, laneId: targetLaneId }); + if (document.visibilityState !== "visible") { + pendingHiddenSessionRefreshRef.current = true; + return; + } + scheduleBackgroundRefresh(delayMs); + }, [scheduleBackgroundRefresh]); + const upsertOptimisticChatSession = useCallback((session: AgentChatSession) => { if (!laneId || session.laneId !== laneId) return; const laneName = currentLane?.name ?? lanes.find((lane) => lane.id === session.laneId)?.name ?? session.laneId; @@ -299,6 +315,10 @@ export function useLaneWorkSessions(laneId: string | null) { }); }, [currentLane?.name, laneId, lanes, scopeKey]); + useEffect(() => { + sessionIdsRef.current = new Set(sessions.map((session) => session.id)); + }, [sessions]); + const upsertSessionSnapshot = useCallback((session: TerminalSessionSummary) => { if (!laneId || session.laneId !== laneId) return; hasLoadedOnceRef.current = true; @@ -316,6 +336,7 @@ export function useLaneWorkSessions(laneId: string | null) { setSessions(cachedSessions ?? []); hasLoadedOnceRef.current = Boolean(cachedSessions); hasFetchedOnceRef.current = false; + pendingHiddenSessionRefreshRef.current = false; if (!laneId) return; void refresh({ showLoading: !cachedSessions, force: !cachedSessions }); }, [laneId, refresh, scopeKey]); @@ -333,7 +354,8 @@ export function useLaneWorkSessions(laneId: string | null) { const unsubscribe = window.ade.pty.onExit((event) => { if (!laneId) return; if (event.projectRoot && event.projectRoot !== projectRoot) return; - scheduleBackgroundRefresh(120); + if (event.sessionId && !sessionIdsRef.current.has(event.sessionId)) return; + markSessionListDirtyOrRefresh(120); }); return () => { try { @@ -342,25 +364,46 @@ export function useLaneWorkSessions(laneId: string | null) { // ignore } }; - }, [laneId, projectRoot, scheduleBackgroundRefresh]); + }, [laneId, projectRoot, markSessionListDirtyOrRefresh]); useEffect(() => { const unsubscribe = window.ade.agentChat.onEvent((payload) => { if (!laneId) return; + if (payload.provenance?.laneId && payload.provenance.laneId !== laneId) return; if (!shouldRefreshSessionListForChatEvent(payload)) return; - scheduleBackgroundRefresh(180); + markSessionListDirtyOrRefresh(180); }); return unsubscribe; - }, [laneId, scheduleBackgroundRefresh]); + }, [laneId, markSessionListDirtyOrRefresh]); useEffect(() => { - const unsubscribe = window.ade.sessions.onChanged(() => { + const unsubscribe = window.ade.sessions.onChanged((event) => { if (!laneId) return; - if (document.visibilityState !== "visible") return; - invalidateSessionListCache(); - scheduleBackgroundRefresh(80); + if (!event) { + markSessionListDirtyOrRefresh(80); + return; + } + if (event.reason !== "created" && !sessionIdsRef.current.has(event.sessionId)) return; + markSessionListDirtyOrRefresh(80); }); return unsubscribe; + }, [laneId, markSessionListDirtyOrRefresh]); + + useEffect(() => { + if (!laneId) return; + const refreshVisibleLaneWork = () => { + if (document.visibilityState !== "visible") return; + if (!pendingHiddenSessionRefreshRef.current) return; + pendingHiddenSessionRefreshRef.current = false; + invalidateSessionListCache({ projectRoot: projectRootRef.current, laneId: laneIdRef.current }); + scheduleBackgroundRefresh(40); + }; + window.addEventListener("focus", refreshVisibleLaneWork); + document.addEventListener("visibilitychange", refreshVisibleLaneWork); + return () => { + window.removeEventListener("focus", refreshVisibleLaneWork); + document.removeEventListener("visibilitychange", refreshVisibleLaneWork); + }; }, [laneId, scheduleBackgroundRefresh]); const activeSessions = useMemo( diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index e1d608de9..3bd736cec 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -473,6 +473,27 @@ describe("TerminalView", () => { expect(setDataSubscriptions).toHaveBeenLastCalledWith({ ptyIds: [] }); }); + it("keeps parked terminal runtimes out of the interactive tree", async () => { + const view = render(); + await flushAnimationFrame(); + + view.unmount(); + + const parking = document.querySelector("[data-ade-terminal-parking='true']"); + expect(parking).toBeTruthy(); + expect(parking?.getAttribute("aria-hidden")).toBe("true"); + expect(parking?.hasAttribute("inert")).toBe(true); + expect(parking?.tabIndex).toBe(-1); + expect(parking?.style.visibility).toBe("hidden"); + expect(parking?.style.contain).toBe("strict"); + + const parkedHost = parking?.firstElementChild as HTMLElement | null; + expect(parkedHost).toBeTruthy(); + expect(parkedHost?.getAttribute("aria-hidden")).toBe("true"); + expect(parkedHost?.hasAttribute("inert")).toBe(true); + expect(parkedHost?.tabIndex).toBe(-1); + }); + it("uses the DOM renderer on Linux when localStorage is unavailable", async () => { vi.useRealTimers(); const platformDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "platform"); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index dd8d0b1fe..fe1c61b43 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -420,18 +420,40 @@ function serializeSnapshotVisibleRows(snapshot: TerminalSerializedSnapshot): str return parts.join(""); } +function configureParkedRoot(root: HTMLDivElement): void { + root.setAttribute("data-ade-terminal-parking", "true"); + root.setAttribute("aria-hidden", "true"); + root.setAttribute("inert", ""); + root.tabIndex = -1; + root.style.position = "fixed"; + root.style.width = "0"; + root.style.height = "0"; + root.style.overflow = "hidden"; + root.style.opacity = "0"; + root.style.pointerEvents = "none"; + root.style.left = "0"; + root.style.top = "0"; + root.style.visibility = "hidden"; + root.style.contain = "strict"; +} + function ensureParkedRoot(): HTMLDivElement { - if (parkedRoot && parkedRoot.isConnected) return parkedRoot; + const existing = parkedRoot && parkedRoot.isConnected + ? parkedRoot + : document.querySelector("[data-ade-terminal-parking='true']"); + if (existing) { + for (const duplicate of document.querySelectorAll("[data-ade-terminal-parking='true']")) { + if (duplicate === existing) continue; + while (duplicate.firstChild) existing.appendChild(duplicate.firstChild); + duplicate.remove(); + } + configureParkedRoot(existing); + parkedRoot = existing; + return existing; + } + const next = document.createElement("div"); - next.setAttribute("data-ade-terminal-parking", "true"); - next.style.position = "fixed"; - next.style.width = "0"; - next.style.height = "0"; - next.style.overflow = "hidden"; - next.style.opacity = "0"; - next.style.pointerEvents = "none"; - next.style.left = "0"; - next.style.top = "0"; + configureParkedRoot(next); document.body.appendChild(next); parkedRoot = next; return next; @@ -594,6 +616,7 @@ function clearDisposeTimer(runtime: CachedRuntime) { } function parkRuntime(runtime: CachedRuntime) { + setRuntimeInteractionState(runtime, false); const parking = ensureParkedRoot(); if (runtime.host.parentElement !== parking) { parking.appendChild(runtime.host); @@ -632,12 +655,20 @@ function setRuntimeInteractionState(runtime: CachedRuntime, active: boolean) { // ignore } try { - runtime.host.toggleAttribute("aria-hidden", !active); + if (active) { + runtime.host.removeAttribute("aria-hidden"); + } else { + runtime.host.setAttribute("aria-hidden", "true"); + } } catch { // ignore } try { - runtime.host.toggleAttribute("inert", !active); + if (active) { + runtime.host.removeAttribute("inert"); + } else { + runtime.host.setAttribute("inert", ""); + } } catch { // ignore } @@ -2273,6 +2304,9 @@ export function TerminalView({ return (
{ }); vi.mock("./TerminalView", () => ({ - TerminalView: ({ sessionId, isActive }: { sessionId: string; isActive: boolean }) => ( -
+ TerminalView: ({ sessionId, isActive, isVisible = isActive }: { sessionId: string; isActive: boolean; isVisible?: boolean }) => ( +
), })); @@ -153,6 +158,7 @@ const slashCommandsMock = vi.fn(); const modelsMock = vi.fn(); const sendToSessionMock = vi.fn(); const resumeSessionMock = vi.fn(); +const resourceUsageMock = vi.fn(); const resolvePtyLaunch = async () => ({ sessionId: "test-session", ptyId: "test-pty", pid: null }); beforeEach(() => { @@ -186,11 +192,29 @@ beforeEach(() => { sendToSessionMock.mockResolvedValue({ sessionId: "session-1", ptyId: "pty-1", pid: 123, session: null, resumed: true, reusedExistingRuntime: false }); resumeSessionMock.mockReset(); resumeSessionMock.mockResolvedValue({ sessionId: "session-1", ptyId: "pty-1", pid: 123, session: null, resumed: true, reusedExistingRuntime: false }); + resourceUsageMock.mockReset(); + resourceUsageMock.mockResolvedValue({ + sampledAt: "2026-04-06T12:00:00.000Z", + processCount: 2, + cpuPercent: 1, + mainCpuPercent: 0.5, + rendererCpuPercent: 0.5, + memoryMB: 200, + mainMemoryMB: 80, + rendererMemoryMB: 120, + activePtyCount: 0, + ptyProcessCount: 0, + ptyCpuPercent: 0, + ptyMemoryMB: 0, + freeMemoryMB: 12_000, + totalMemoryMB: 16_000, + }); Object.defineProperty(window, "ade", { configurable: true, value: { app: { writeClipboardText: vi.fn().mockResolvedValue(undefined), + getResourceUsage: resourceUsageMock, }, agentChat: { models: modelsMock, @@ -594,6 +618,92 @@ describe("WorkViewArea", () => { ); expect(screen.getAllByTestId("terminal-view")).toHaveLength(2); + expect(screen.getAllByTestId("terminal-view").map((node) => node.getAttribute("data-visible"))).toEqual(["true", "true"]); + }); + + it("cools background grid terminal streams under app pressure but keeps the focused terminal live", async () => { + resourceUsageMock.mockResolvedValue({ + sampledAt: "2026-04-06T12:00:00.000Z", + processCount: 8, + cpuPercent: 96, + mainCpuPercent: 12, + rendererCpuPercent: 96, + memoryMB: 7_000, + mainMemoryMB: 400, + rendererMemoryMB: 2_200, + activePtyCount: 24, + ptyProcessCount: 36, + ptyCpuPercent: 96, + ptyMemoryMB: 4_400, + freeMemoryMB: 600, + totalMemoryMB: 16_000, + }); + const sessions = Array.from({ length: 24 }, (_unused, index) => ( + makeRunningSession(`session-${index + 1}`, `pty-${index + 1}`) + )); + const lane = { + id: "lane-1", + name: "Lane 1", + laneType: "worktree" as const, + baseRef: "main", + branchRef: "lane-1", + worktreePath: "/tmp/lane-1", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + createdAt: "2026-04-06T12:00:00.000Z", + }; + + const renderGrid = (activeItemId: string) => ( + session.id)} + activeItemId={activeItemId} + viewMode="grid" + draftKind="chat" + setViewMode={() => {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={resolvePtyLaunch} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + /> + ); + + const view = render(renderGrid("session-1")); + + let cooledSessionId: string | null = null; + await waitFor(() => { + const terminals = within(view.container).getAllByTestId("terminal-view"); + expect(terminals).toHaveLength(sessions.length); + expect(terminals.find((node) => node.getAttribute("data-session-id") === "session-1")?.getAttribute("data-visible")).toBe("true"); + cooledSessionId = terminals.find((node) => ( + node.getAttribute("data-session-id") !== "session-1" + && node.getAttribute("data-visible") === "false" + ))?.getAttribute("data-session-id") ?? null; + expect(cooledSessionId).toBeTruthy(); + }); + + view.rerender(renderGrid(cooledSessionId!)); + + await waitFor(() => { + const focused = within(view.container).getAllByTestId("terminal-view") + .find((node) => node.getAttribute("data-session-id") === cooledSessionId); + expect(focused?.getAttribute("data-active")).toBe("true"); + expect(focused?.getAttribute("data-visible")).toBe("true"); + }); }); it("adds the CLI session header above agent PTY sessions", () => { diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 5d4a22734..b194df093 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -24,6 +24,7 @@ import { import type { AgentChatSession, AgentChatSlashCommand, + AppResourceUsageSnapshot, ChatTerminalPreviewResult, LaneLinearIssue, LaneSummary, @@ -53,6 +54,13 @@ import { buildWorkSessionTilingTree, type TilingPreset } from "./workSessionTili import { laneSurfaceTint } from "../lanes/laneDesignTokens"; import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; +import { + appResourcePressureLevel, + clampPressureLevel, + getAppResourceUsageCoalesced, + pressureLevelForThresholds, + type ResourcePressureLevel, +} from "../../lib/resourcePressure"; function isSessionAwaitingInput(session: TerminalSessionSummary): boolean { return sessionNeedsChatTabHighlight({ @@ -82,6 +90,178 @@ function isAgentCliSession(session: TerminalSessionSummary): boolean { ); } +type GridTerminalPressureLevel = ResourcePressureLevel; + +type GridTerminalRefreshPolicy = { + level: GridTerminalPressureLevel; + bucketCount: number; + pulseMs: number; +}; + +const GRID_TERMINAL_PRESSURE_SAMPLE_MS = 1_000; +const GRID_TERMINAL_RESOURCE_SAMPLE_MS = 2_000; +const NORMAL_GRID_TERMINAL_REFRESH_POLICY: GridTerminalRefreshPolicy = { + level: 0, + bucketCount: 1, + pulseMs: 0, +}; + +function readRendererHeapRatio(): number | null { + const perf = performance as Performance & { + memory?: { + usedJSHeapSize?: number; + totalJSHeapSize?: number; + jsHeapSizeLimit?: number; + }; + }; + const memory = perf.memory; + const used = memory?.usedJSHeapSize; + const limit = memory?.jsHeapSizeLimit; + if ( + typeof used !== "number" + || !Number.isFinite(used) + || typeof limit !== "number" + || !Number.isFinite(limit) + || limit <= 0 + ) { + return null; + } + return used / limit; +} + +function pressureLevelForSignals(args: { + driftMs: number; + rendererHeapRatio: number | null; + usage: AppResourceUsageSnapshot | null; +}): GridTerminalPressureLevel { + const driftLevel = pressureLevelForThresholds(args.driftMs, [80, 180, 350, 700]); + const heapLevel = pressureLevelForThresholds(args.rendererHeapRatio, [0.55, 0.68, 0.78, 0.88]); + const resourceLevel = appResourcePressureLevel(args.usage); + return clampPressureLevel(Math.max(driftLevel, heapLevel, resourceLevel)); +} + +function stabilizePressureLevel( + previous: GridTerminalPressureLevel, + sampled: GridTerminalPressureLevel, +): GridTerminalPressureLevel { + if (sampled >= previous) return sampled; + return clampPressureLevel(Math.max(sampled, previous - 1)); +} + +function gridTerminalRefreshPolicyForPressure(level: GridTerminalPressureLevel): GridTerminalRefreshPolicy { + switch (level) { + case 1: + return { level, bucketCount: 2, pulseMs: 650 }; + case 2: + return { level, bucketCount: 4, pulseMs: 900 }; + case 3: + return { level, bucketCount: 8, pulseMs: 1_200 }; + case 4: + return { level, bucketCount: 16, pulseMs: 1_600 }; + default: + return NORMAL_GRID_TERMINAL_REFRESH_POLICY; + } +} + +function stableBucketForSession(sessionId: string, bucketCount: number): number { + if (bucketCount <= 1) return 0; + let hash = 2166136261; + for (let index = 0; index < sessionId.length; index += 1) { + hash ^= sessionId.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) % bucketCount; +} + +function shouldStreamGridTerminal(args: { + sessionId: string; + isActive: boolean; + policy: GridTerminalRefreshPolicy; + pulse: number; +}): boolean { + if (args.isActive || args.policy.level === 0) return true; + const bucket = stableBucketForSession(args.sessionId, args.policy.bucketCount); + return bucket === (args.pulse % args.policy.bucketCount); +} + +function useAdaptiveGridTerminalRefresh(enabled: boolean): { policy: GridTerminalRefreshPolicy; pulse: number } { + const [policy, setPolicy] = useState(NORMAL_GRID_TERMINAL_REFRESH_POLICY); + const [pulse, setPulse] = useState(0); + const latestUsageRef = useRef(null); + + useEffect(() => { + if (!enabled) { + latestUsageRef.current = null; + setPolicy(NORMAL_GRID_TERMINAL_REFRESH_POLICY); + setPulse(0); + return; + } + + let disposed = false; + let lastTick = performance.now(); + let lastResourceSampleAt = 0; + + const updatePolicy = (driftMs: number, usage: AppResourceUsageSnapshot | null) => { + const sampled = pressureLevelForSignals({ + driftMs, + rendererHeapRatio: readRendererHeapRatio(), + usage, + }); + setPolicy((previous) => { + const level = stabilizePressureLevel(previous.level, sampled); + return level === previous.level ? previous : gridTerminalRefreshPolicyForPressure(level); + }); + }; + + const sample = () => { + const now = performance.now(); + if (document.visibilityState !== "visible") { + lastTick = now; + return; + } + const driftMs = Math.max(0, now - lastTick - GRID_TERMINAL_PRESSURE_SAMPLE_MS); + lastTick = now; + updatePolicy(driftMs, latestUsageRef.current); + + if (lastResourceSampleAt > 0 && now - lastResourceSampleAt < GRID_TERMINAL_RESOURCE_SAMPLE_MS) return; + lastResourceSampleAt = now; + getAppResourceUsageCoalesced() + .then((usage) => { + if (disposed) return; + latestUsageRef.current = usage; + updatePolicy(0, usage); + }) + .catch(() => {}); + }; + + sample(); + const interval = window.setInterval(sample, GRID_TERMINAL_PRESSURE_SAMPLE_MS); + const sampleWhenVisible = () => { + if (document.visibilityState === "visible") sample(); + }; + document.addEventListener("visibilitychange", sampleWhenVisible); + return () => { + disposed = true; + window.clearInterval(interval); + document.removeEventListener("visibilitychange", sampleWhenVisible); + }; + }, [enabled]); + + useEffect(() => { + if (!enabled || policy.level === 0) { + setPulse(0); + return; + } + const interval = window.setInterval(() => { + if (document.visibilityState !== "visible") return; + setPulse((current) => current + 1); + }, policy.pulseMs); + return () => window.clearInterval(interval); + }, [enabled, policy.level, policy.pulseMs]); + + return { policy, pulse }; +} + function stoppedBySignal(exitCode: number | null | undefined): boolean { return exitCode === 130 || exitCode === 143; } @@ -1335,6 +1515,10 @@ export function WorkViewArea({ () => buildWorkSessionTilingTree(JSON.parse(gridSessionIdsKey) as string[], tilingPreset), [gridSessionIdsKey, tilingPreset], ); + const { + policy: gridTerminalRefreshPolicy, + pulse: gridTerminalRefreshPulse, + } = useAdaptiveGridTerminalRefresh(viewMode === "grid" && pageActive); const applyTilingPreset = useCallback(async (preset: TilingPreset) => { const ids = JSON.parse(gridSessionIdsKey) as string[]; const nextTree = buildWorkSessionTilingTree(ids, preset); @@ -1354,6 +1538,12 @@ export function WorkViewArea({ const isBusy = session.ptyId ? closingPtyIds.has(session.ptyId) : false; const isCliAgentSession = isAgentCliSession(session); const isActive = activeItemId === session.id; + const terminalVisible = !isRunningPtySession(session) || shouldStreamGridTerminal({ + sessionId: session.id, + isActive, + policy: gridTerminalRefreshPolicy, + pulse: gridTerminalRefreshPulse, + }); const rawLaneColor = laneColorById.get(session.laneId) ?? null; const laneAccentColor = rawLaneColor?.trim() ? rawLaneColor.trim() : null; const openInTabs = () => onOpenSessionInTabsView?.(session.id); @@ -1418,7 +1608,7 @@ export function WorkViewArea({ isActive={isActive} pageActive={pageActive} shouldAutofocus={isActive} - terminalVisible + terminalVisible={terminalVisible} layoutVariant="grid-tile" onInfoClick={onInfoClick} onContextMenu={onContextMenu} @@ -1435,6 +1625,8 @@ export function WorkViewArea({ ), [ activeItemId, closingPtyIds, + gridTerminalRefreshPolicy, + gridTerminalRefreshPulse, handleContextMenu, lanes, laneColorById, @@ -1697,7 +1889,12 @@ export function WorkViewArea({ if (viewMode === "grid") { return ( -
+
{placeWorkGlassHeader( { installWindowAde(); listSessionsCachedMock.mockResolvedValue([]); useSearchParamsMock.mockReturnValue([new URLSearchParams(), vi.fn()]); + setDocumentVisibility("visible"); }); afterEach(() => { + setDocumentVisibility("visible"); delete (window as any).ade; }); @@ -406,7 +415,7 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { }); }); - expect(listSessionsCachedMock).toHaveBeenCalledWith({ limit: 500 }, { force: true, projectRoot: "/fake/project" }); + expect(listSessionsCachedMock).toHaveBeenCalledWith({ limit: 500 }, { force: true }); expect(result.current.sessions).toEqual([ expect.objectContaining({ id: "new-pty-session", @@ -694,7 +703,7 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { await waitFor(() => { expect(listSessionsCachedMock).toHaveBeenCalled(); }); - expect(listSessionsCachedMock).toHaveBeenLastCalledWith({ limit: 500 }, { projectRoot: "/fake/project" }); + expect(listSessionsCachedMock).toHaveBeenLastCalledWith({ limit: 500 }, undefined); }); it("setActiveItemId leaves the selected lane alone in grid mode", async () => { @@ -1416,10 +1425,47 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { await new Promise((r) => setTimeout(r, 120)); }); - expect(listSessionsCachedMock).toHaveBeenCalledWith({ limit: 500 }, { projectRoot: "/fake/project" }); + expect(listSessionsCachedMock).toHaveBeenCalledWith({ limit: 500 }, undefined); expect(invalidateSessionListCache).toHaveBeenCalled(); }); + it("defers hidden session-list changes and refreshes Work on reveal", async () => { + let onChangedHandler: (() => void) | null = null; + (window as any).ade.sessions.onChanged.mockImplementation((cb: () => void) => { + onChangedHandler = cb; + return () => { + onChangedHandler = null; + }; + }); + + renderHook(() => useWorkSessions()); + + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + listSessionsCachedMock.mockClear(); + vi.mocked(invalidateSessionListCache).mockClear(); + listSessionsCachedMock.mockResolvedValue([makeSession("session-revealed", "lane-1")]); + + setDocumentVisibility("hidden"); + await act(async () => { + onChangedHandler?.(); + await new Promise((r) => setTimeout(r, 120)); + }); + + expect(invalidateSessionListCache).toHaveBeenCalled(); + expect(listSessionsCachedMock).not.toHaveBeenCalled(); + + setDocumentVisibility("visible"); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + await new Promise((r) => setTimeout(r, 60)); + }); + + expect(listSessionsCachedMock).toHaveBeenCalledWith({ limit: 500 }, undefined); + }); + it("refetches visible Work when the window regains focus", async () => { renderHook(() => useWorkSessions()); @@ -1436,7 +1482,7 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { }); expect(invalidateSessionListCache).toHaveBeenCalled(); - expect(listSessionsCachedMock).toHaveBeenCalledWith({ limit: 500 }, { projectRoot: "/fake/project" }); + expect(listSessionsCachedMock).toHaveBeenCalledWith({ limit: 500 }, undefined); }); it("does not subscribe or refresh while the kept-alive Work surface is inactive", async () => { @@ -1485,7 +1531,7 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { }); expect(invalidateSessionListCache).toHaveBeenCalled(); - expect(listSessionsCachedMock).toHaveBeenCalledWith({ limit: 500 }, { projectRoot: "/fake/project" }); + expect(listSessionsCachedMock).toHaveBeenCalledWith({ limit: 500 }, undefined); }); }); diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index cd1c8298c..8c08c748a 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -365,6 +365,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const pendingOptimisticSessionsRef = useRef>(new Map()); const hasRunningSessionsRef = useRef(false); const backgroundRefreshTimerRef = useRef(null); + const pendingHiddenSessionRefreshRef = useRef(false); const appliedQuerySessionIdRef = useRef(null); const appliedUrlFilterKeyRef = useRef(null); const partiallyAppliedUrlFilterKeyRef = useRef(null); @@ -771,8 +772,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) await listSessionsCached( { limit: 500 }, options.force - ? { force: true, projectRoot: requestedProjectRoot } - : { projectRoot: requestedProjectRoot }, + ? { force: true } + : undefined, ) ).filter((session) => !isRunOwnedSession(session)); if (projectRootRef.current !== requestedProjectRoot) { @@ -845,10 +846,23 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) if (backgroundRefreshTimerRef.current != null) return; backgroundRefreshTimerRef.current = window.setTimeout(() => { backgroundRefreshTimerRef.current = null; + if (document.visibilityState !== "visible") { + pendingHiddenSessionRefreshRef.current = true; + return; + } void refresh({ showLoading: false }); }, delayMs); }, [isWorkRoute, refresh]); + const markSessionListDirtyOrRefresh = useCallback((delayMs: number) => { + invalidateSessionListCache(); + if (document.visibilityState !== "visible") { + pendingHiddenSessionRefreshRef.current = true; + return; + } + scheduleBackgroundRefresh(delayMs); + }, [scheduleBackgroundRefresh]); + useEffect(() => { // Apply the per-project sessions cache immediately so switching back to a // warm project does NOT blank the chat tabs / terminal grid. Without this @@ -876,6 +890,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) appliedUrlFilterKeyRef.current = null; partiallyAppliedUrlFilterKeyRef.current = null; pendingOptimisticSessionsRef.current.clear(); + pendingHiddenSessionRefreshRef.current = false; }, [appStore, projectRoot]); useLayoutEffect(() => { @@ -1054,7 +1069,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const unsubExit = window.ade.pty.onExit((event) => { const currentProjectRoot = projectRootRef.current; if (event.projectRoot && event.projectRoot !== currentProjectRoot) return; - scheduleBackgroundRefresh(120); + markSessionListDirtyOrRefresh(120); }); const t = setInterval(() => { if (document.visibilityState !== "visible") return; @@ -1069,28 +1084,24 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) } clearInterval(t); }; - }, [isWorkRoute, scheduleBackgroundRefresh]); + }, [isWorkRoute, markSessionListDirtyOrRefresh, scheduleBackgroundRefresh]); useEffect(() => { if (!isWorkRoute) return; const unsubscribe = window.ade.agentChat.onEvent((payload) => { - if (document.visibilityState !== "visible") return; if (!shouldRefreshSessionListForChatEvent(payload)) return; - invalidateSessionListCache(); - scheduleBackgroundRefresh(220); + markSessionListDirtyOrRefresh(220); }); return unsubscribe; - }, [isWorkRoute, scheduleBackgroundRefresh]); + }, [isWorkRoute, markSessionListDirtyOrRefresh]); useEffect(() => { if (!isWorkRoute) return; const unsubscribe = window.ade.sessions.onChanged(() => { - if (document.visibilityState !== "visible") return; - invalidateSessionListCache(); - scheduleBackgroundRefresh(80); + markSessionListDirtyOrRefresh(80); }); return unsubscribe; - }, [isWorkRoute, scheduleBackgroundRefresh]); + }, [isWorkRoute, markSessionListDirtyOrRefresh]); useEffect(() => { return () => { @@ -1104,8 +1115,10 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) if (!isWorkRoute) return; const refreshVisibleWork = () => { if (document.visibilityState !== "visible") return; + const hadHiddenChanges = pendingHiddenSessionRefreshRef.current; + pendingHiddenSessionRefreshRef.current = false; invalidateSessionListCache(); - scheduleBackgroundRefresh(120); + scheduleBackgroundRefresh(hadHiddenChanges ? 20 : 120); }; window.addEventListener("focus", refreshVisibleWork); document.addEventListener("visibilitychange", refreshVisibleWork); diff --git a/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx b/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx index b2c22eac1..e71882cd3 100644 --- a/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx +++ b/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx @@ -39,6 +39,7 @@ export type PaneConfig = { hideHeaderWhenExpanded?: boolean; onPaneMouseDown?: (e: React.MouseEvent) => void; onPaneContextMenu?: (e: React.MouseEvent) => void; + renderChildren?: (args: { minimized: boolean; paneId: string; layoutId: string }) => React.ReactNode; children: React.ReactNode; }; @@ -383,6 +384,10 @@ export function PaneTilingLayout({ const currentDropEdge = dropTargetId === paneId && dragSourceId !== paneId ? dropEdge : null; + const children = config.renderChildren + ? config.renderChildren({ minimized: isMinimized, paneId, layoutId }) + : config.children; + return ( - {config.children} + {children} ); } diff --git a/apps/desktop/src/renderer/lib/resourcePressure.test.ts b/apps/desktop/src/renderer/lib/resourcePressure.test.ts new file mode 100644 index 000000000..7c3fb7491 --- /dev/null +++ b/apps/desktop/src/renderer/lib/resourcePressure.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { AppResourceUsageSnapshot } from "../../shared/types"; +import { + appResourcePressureLevel, + clampPressureLevel, + pressureLevelForThresholds, + resourcePressureDescription, +} from "./resourcePressure"; + +function makeUsage(overrides: Partial): AppResourceUsageSnapshot { + return { + sampledAt: "2026-04-06T12:00:00.000Z", + processCount: 1, + cpuPercent: 0, + mainCpuPercent: 0, + rendererCpuPercent: 0, + memoryMB: 0, + mainMemoryMB: 0, + rendererMemoryMB: 0, + activePtyCount: 0, + ptyProcessCount: 0, + ptyCpuPercent: 0, + ptyMemoryMB: 0, + freeMemoryMB: 8_000, + totalMemoryMB: 16_000, + ...overrides, + }; +} + +describe("resourcePressure", () => { + it("keeps zero free system memory visible in the pressure description", () => { + const usage = makeUsage({ + freeMemoryMB: 0, + totalMemoryMB: 16_000, + }); + + expect(appResourcePressureLevel(usage)).toBe(0); + expect(resourcePressureDescription(usage)).toContain("100% system memory used"); + }); + + it("uses shared truncating clamp and threshold helpers", () => { + expect(clampPressureLevel(2.8)).toBe(2); + expect(clampPressureLevel(9)).toBe(4); + expect(pressureLevelForThresholds(70, [30, 50, 70, 90])).toBe(3); + expect(pressureLevelForThresholds(null, [30, 50, 70, 90])).toBe(0); + }); +}); diff --git a/apps/desktop/src/renderer/lib/resourcePressure.ts b/apps/desktop/src/renderer/lib/resourcePressure.ts new file mode 100644 index 000000000..d54e50305 --- /dev/null +++ b/apps/desktop/src/renderer/lib/resourcePressure.ts @@ -0,0 +1,95 @@ +import type { AppResourceUsageSnapshot } from "../../shared/types"; + +export type ResourcePressureLevel = 0 | 1 | 2 | 3 | 4; + +let resourceUsageRequest: Promise | null = null; + +export function getAppResourceUsageCoalesced(): Promise { + if (resourceUsageRequest) return resourceUsageRequest; + const getResourceUsage = window.ade?.app?.getResourceUsage; + if (typeof getResourceUsage !== "function") return Promise.resolve(null); + + const request = getResourceUsage() + .catch(() => null) + .finally(() => { + if (resourceUsageRequest === request) resourceUsageRequest = null; + }); + resourceUsageRequest = request; + return request; +} + +export function clampPressureLevel(value: number): ResourcePressureLevel { + if (!Number.isFinite(value) || value <= 0) return 0; + if (value >= 4) return 4; + return Math.trunc(value) as ResourcePressureLevel; +} + +export function pressureLevelForThresholds( + value: number | null | undefined, + thresholds: [number, number, number, number], +): ResourcePressureLevel { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + if (value >= thresholds[3]) return 4; + if (value >= thresholds[2]) return 3; + if (value >= thresholds[1]) return 2; + if (value >= thresholds[0]) return 1; + return 0; +} + +export function appResourcePressureLevel(usage: AppResourceUsageSnapshot | null): ResourcePressureLevel { + if (!usage) return 0; + const cpu = Math.max( + usage.cpuPercent ?? 0, + usage.mainCpuPercent ?? 0, + usage.rendererCpuPercent ?? 0, + usage.ptyCpuPercent ?? 0, + ); + const cpuLevel = pressureLevelForThresholds(cpu, [30, 50, 70, 90]); + const freeMemoryRatio = usage.freeMemoryMB != null && usage.totalMemoryMB + ? usage.freeMemoryMB / usage.totalMemoryMB + : null; + const freeMemoryLevel = typeof freeMemoryRatio === "number" + ? pressureLevelForThresholds(1 - freeMemoryRatio, [0.82, 0.88, 0.93, 0.96]) + : 0; + const appMemoryRatio = usage.memoryMB != null && usage.totalMemoryMB + ? usage.memoryMB / usage.totalMemoryMB + : null; + const appMemoryLevel = pressureLevelForThresholds(appMemoryRatio, [0.08, 0.14, 0.20, 0.28]); + const hasAdeLoad = cpuLevel > 0 || appMemoryLevel > 0; + return clampPressureLevel(Math.max(cpuLevel, hasAdeLoad ? freeMemoryLevel : 0, appMemoryLevel)); +} + +function formatPercent(value: number | null | undefined): string | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + if (value < 1) return null; + return `${Math.round(value)}% CPU`; +} + +function formatMemory(value: number | null | undefined): string | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + if (value < 1) return null; + if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`; + return `${Math.round(value)} MB`; +} + +function formatSystemMemory(usage: AppResourceUsageSnapshot | null): string | null { + if (usage?.freeMemoryMB == null || usage.totalMemoryMB == null || usage.totalMemoryMB <= 0) return null; + const usedRatio = 1 - (usage.freeMemoryMB / usage.totalMemoryMB); + if (!Number.isFinite(usedRatio)) return null; + return `${Math.round(usedRatio * 100)}% system memory used`; +} + +function preferPositiveMetric(primary: number | null | undefined, fallback: number | null | undefined): number | null | undefined { + return typeof primary === "number" && Number.isFinite(primary) && primary >= 1 ? primary : fallback; +} + +export function resourcePressureDescription(usage: AppResourceUsageSnapshot | null): string { + const details = [ + usage?.activePtyCount ? `${usage.activePtyCount} live terminal${usage.activePtyCount === 1 ? "" : "s"}` : null, + formatPercent(preferPositiveMetric(usage?.ptyCpuPercent, usage?.cpuPercent)), + formatMemory(preferPositiveMetric(usage?.ptyMemoryMB, usage?.memoryMB)), + ].filter((part): part is string => Boolean(part)); + const fallbackDetails = details.length > 0 ? details : [formatSystemMemory(usage)].filter((part): part is string => Boolean(part)); + const detailText = fallbackDetails.length > 0 ? ` (${fallbackDetails.join(", ")})` : ""; + return `ADE is under load${detailText}. Background live refreshes are slowed; selected chats and terminals stay full speed.`; +} diff --git a/apps/desktop/src/renderer/lib/sessionListCache.test.ts b/apps/desktop/src/renderer/lib/sessionListCache.test.ts index e6e20d754..1bebb09a3 100644 --- a/apps/desktop/src/renderer/lib/sessionListCache.test.ts +++ b/apps/desktop/src/renderer/lib/sessionListCache.test.ts @@ -104,6 +104,16 @@ describe("sessionListCache", () => { expect(listMock).toHaveBeenCalledTimes(2); }); + it("normalizes tool types before fetching and cache keying", async () => { + listMock.mockResolvedValueOnce(makeRows(2)); + + await listSessionsCached({ toolTypes: [" codex-chat ", "", "codex-chat"] as any }); + await listSessionsCached({ toolTypes: ["codex-chat"] }); + + expect(listMock).toHaveBeenCalledTimes(1); + expect(listMock).toHaveBeenCalledWith({ toolTypes: ["codex-chat"] }); + }); + it("shares an in-flight request with forced callers", async () => { let resolveRows!: (rows: ReturnType) => void; listMock.mockImplementationOnce(() => new Promise>((resolve) => { @@ -136,4 +146,51 @@ describe("sessionListCache", () => { await expect(first).resolves.toHaveLength(10); await expect(second).resolves.toHaveLength(5); }); + + it("invalidates only the matching project and lane when scoped", async () => { + listMock + .mockResolvedValueOnce(makeRows(2)) + .mockResolvedValueOnce(makeRows(3)) + .mockResolvedValueOnce(makeRows(4)); + + await listSessionsCached({ laneId: "lane-1", limit: 2 }); + await listSessionsCached({ laneId: "lane-2", limit: 3 }); + useAppStore.setState({ + project: { rootPath: "/project/b" } as any, + }); + await listSessionsCached({ laneId: "lane-1", limit: 4 }); + + invalidateSessionListCache({ projectRoot: "/project/a", laneId: "lane-1" }); + + listMock.mockResolvedValueOnce(makeRows(5)); + useAppStore.setState({ + project: { rootPath: "/project/a" } as any, + }); + const invalidated = await listSessionsCached({ laneId: "lane-1", limit: 2 }); + const sameProjectOtherLane = await listSessionsCached({ laneId: "lane-2", limit: 3 }); + useAppStore.setState({ + project: { rootPath: "/project/b" } as any, + }); + const sameLaneOtherProject = await listSessionsCached({ laneId: "lane-1", limit: 4 }); + + expect(invalidated).toHaveLength(2); + expect(sameProjectOtherLane).toHaveLength(3); + expect(sameLaneOtherProject).toHaveLength(4); + expect(listMock).toHaveBeenCalledTimes(4); + }); + + it("ignores projectRoot-like cache options that the sessions IPC cannot honor", async () => { + listMock + .mockResolvedValueOnce(makeRows(2)) + .mockResolvedValueOnce(makeRows(4)); + + await listSessionsCached({ limit: 2 }, { projectRoot: "/project/b" } as any); + useAppStore.setState({ + project: { rootPath: "/project/b" } as any, + }); + const projectBRows = await listSessionsCached({ limit: 4 }); + + expect(projectBRows).toHaveLength(4); + expect(listMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/desktop/src/renderer/lib/sessionListCache.ts b/apps/desktop/src/renderer/lib/sessionListCache.ts index 0283db8c4..6c566e5a1 100644 --- a/apps/desktop/src/renderer/lib/sessionListCache.ts +++ b/apps/desktop/src/renderer/lib/sessionListCache.ts @@ -1,6 +1,8 @@ import type { ListSessionsArgs, TerminalSessionSummary } from "../../shared/types"; import { useAppStore } from "../state/appStore"; +type SessionToolType = NonNullable[number]; + type CacheEntry = { value: TerminalSessionSummary[] | null; timestamp: number; @@ -11,6 +13,10 @@ type CacheEntry = { const DEFAULT_SESSION_LIST_TTL_MS = 1_500; const cache = new Map(); +type SessionListCacheScope = { + projectRoot?: string | null; + laneId?: string | null; +}; function normalizeArgs(args?: ListSessionsArgs): ListSessionsArgs { if (!args) return {}; @@ -18,6 +24,16 @@ function normalizeArgs(args?: ListSessionsArgs): ListSessionsArgs { if (typeof args.laneId === "string" && args.laneId.trim().length > 0) normalized.laneId = args.laneId.trim(); if (typeof args.status === "string" && args.status.trim().length > 0) normalized.status = args.status; if (typeof args.limit === "number" && Number.isFinite(args.limit) && args.limit > 0) normalized.limit = Math.floor(args.limit); + if (Array.isArray(args.toolTypes)) { + const toolTypes = Array.from( + new Set( + args.toolTypes + .map((toolType) => toolType.trim()) + .filter((toolType): toolType is SessionToolType => toolType.length > 0), + ), + ).sort(); + if (toolTypes.length > 0) normalized.toolTypes = toolTypes; + } return normalized; } @@ -27,6 +43,7 @@ function cacheKey(args?: ListSessionsArgs): string { projectRoot: useAppStore.getState().project?.rootPath?.trim() || null, laneId: normalized.laneId ?? null, status: normalized.status ?? null, + toolTypes: normalized.toolTypes ?? null, }); } @@ -50,15 +67,9 @@ function sliceRows(rows: TerminalSessionSummary[], limit: number | null): Termin export async function listSessionsCached( args?: ListSessionsArgs, - options?: { force?: boolean; ttlMs?: number; projectRoot?: string | null }, + options?: { force?: boolean; ttlMs?: number }, ): Promise { - const key = options?.projectRoot == null - ? cacheKey(args) - : JSON.stringify({ - projectRoot: options.projectRoot?.trim() || null, - laneId: normalizeArgs(args).laneId ?? null, - status: normalizeArgs(args).status ?? null, - }); + const key = cacheKey(args); const ttlMs = options?.ttlMs ?? DEFAULT_SESSION_LIST_TTL_MS; const limit = requestedLimit(args); const now = Date.now(); @@ -111,8 +122,26 @@ export async function listSessionsCached( return request.then((rows) => sliceRows(rows, limit)); } -export function invalidateSessionListCache(): void { +export function invalidateSessionListCache(scope?: SessionListCacheScope): void { + const projectRootFilter = scope + ? scope.projectRoot === undefined ? undefined : scope.projectRoot?.trim() || null + : undefined; + const laneIdFilter = scope + ? scope.laneId === undefined ? undefined : scope.laneId?.trim() || null + : undefined; + for (const [key, entry] of [...cache.entries()]) { + let parsed: { projectRoot?: string | null; laneId?: string | null }; + try { + parsed = JSON.parse(key) as { projectRoot?: string | null; laneId?: string | null }; + } catch { + cache.delete(key); + continue; + } + + if (projectRootFilter !== undefined && parsed.projectRoot !== projectRootFilter) continue; + if (laneIdFilter !== undefined && parsed.laneId !== laneIdFilter) continue; + if (!entry.inFlight) { cache.delete(key); continue; diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 9f4fabdc7..0e74d7eca 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -1,6 +1,7 @@ export const IPC = { appPing: "ade.app.ping", appGetInfo: "ade.app.getInfo", + appGetResourceUsage: "ade.app.getResourceUsage", appGetLatestRelease: "ade.app.getLatestRelease", appGetProject: "ade.app.getProject", appGetWindowSession: "ade.app.getWindowSession", diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index 7ded87940..ecccbec56 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -60,6 +60,26 @@ export type AppInfo = { localRuntime: LocalRuntimeStatus | null; }; +export type PtyProcessResourceUsageSnapshot = { + activePtyCount: number; + ptyProcessCount: number; + ptyCpuPercent: number | null; + ptyMemoryMB: number | null; +}; + +export type AppResourceUsageSnapshot = PtyProcessResourceUsageSnapshot & { + sampledAt: string; + processCount: number; + cpuPercent: number | null; + mainCpuPercent: number | null; + rendererCpuPercent: number | null; + memoryMB: number | null; + mainMemoryMB: number | null; + rendererMemoryMB: number | null; + freeMemoryMB: number | null; + totalMemoryMB: number | null; +}; + export type LatestReleaseInfo = { version: string; htmlUrl: string | null; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 41f58031e..4f20a705e 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -333,6 +333,7 @@ export type ListSessionsArgs = { laneId?: string; status?: TerminalSessionStatus; limit?: number | null; + toolTypes?: TerminalToolType[]; }; export type DeleteSessionArgs = {