From 7dd95aace519771137de9d7b092d0e861f10ce7e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 31 May 2026 14:32:52 -0400 Subject: [PATCH 01/13] perf(lanes): gate stack graph agent rosters Measured with perf-pass run lanes-20260531-1421-after1 against baseline lanes-20260531-1421-baseline. Lanes navigation localRuntime.callAction count dropped 87 -> 28 and runtime IPC duration dropped 25.4s -> 13.3s. Stack Graph open now hydrates agent rosters on demand, adding 63 callAction calls totaling 450ms. --- apps/desktop/src/renderer/components/lanes/LanesPage.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index c963b36db..5b6675aa7 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -141,6 +141,7 @@ type RebasePushReviewState = { const ADOPT_HINT_DISMISSED_KEY = "ade.lanes.adoptHintDismissed.v1"; const LANE_DELETE_REFRESH_DEBOUNCE_MS = 160; +const EMPTY_LANE_IDS: string[] = []; function normalizeLaneRuntimePlacement(value: unknown): LaneRuntimePlacement { return value === "macos-vm" ? "macos-vm" : "local"; @@ -702,8 +703,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], From d630857ce4e033f18b38588da09f542a4fa14400 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 31 May 2026 14:45:25 -0400 Subject: [PATCH 02/13] fix(chat): filter chat session lists before caps --- .../services/chat/agentChatService.test.ts | 31 +++++++++++++ .../main/services/chat/agentChatService.ts | 14 +++++- .../services/sessions/sessionService.test.ts | 39 ++++++++++++++++ .../main/services/sessions/sessionService.ts | 46 ++++++++++++++++++- .../components/lanes/useLaneWorkSessions.ts | 1 + .../src/renderer/lib/sessionListCache.ts | 17 +++++-- apps/desktop/src/shared/types/sessions.ts | 1 + 7 files changed, 144 insertions(+), 5 deletions(-) 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..95c6b2f8e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -2548,6 +2548,14 @@ function describeCodexModel(value: string): string | null { return null; } +const CHAT_SESSION_TOOL_TYPES: TerminalToolType[] = [ + "codex-chat", + "claude-chat", + "opencode-chat", + "cursor", + "droid-chat", +]; + function isChatToolType( toolType: TerminalToolType | null | undefined, ): toolType is "codex-chat" | "claude-chat" | "opencode-chat" | "cursor" | "droid-chat" { @@ -21785,7 +21793,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/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index ad1f9ba19..189d078fd 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,7 @@ describe("sessionService resume metadata", () => { const session = service.get("session-legacy"); expect(session?.toolType).toBe("droid-chat"); expect(session?.resumeCommand).toBe("chat:droid:session-legacy"); + expect(service.list({ laneId: "lane-1", toolTypes: ["droid-chat"] })[0]?.id).toBe("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..ecefbd920 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,7 +339,7 @@ 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)[] = []; @@ -341,6 +351,40 @@ export function createSessionService({ db }: { db: AdeDb }) { where.push("s.status = ?"); params.push(status); } + const normalizedToolTypes = normalizeToolTypes(toolTypes); + if (normalizedToolTypes.length > 0) { + const directPlaceholders = normalizedToolTypes.map(() => "?").join(", "); + const toolTypeClauses = [`s.tool_type in (${directPlaceholders})`]; + const toolTypeParams: (string | number | null)[] = [...normalizedToolTypes]; + const legacyChatClauses: string[] = []; + + for (const toolType of normalizedToolTypes) { + if (toolType === "codex-chat") { + legacyChatClauses.push( + "(s.tool_type = 'other' and (lower(coalesce(s.resume_command, '')) = ? or lower(coalesce(s.resume_command, '')) like ?))", + ); + toolTypeParams.push("chat:codex", "chat:codex:%"); + } else if (toolType === "claude-chat") { + legacyChatClauses.push("(s.tool_type = 'other' and lower(coalesce(s.resume_command, '')) like ?)"); + toolTypeParams.push("chat:claude:%"); + } else if (toolType === "opencode-chat") { + legacyChatClauses.push("(s.tool_type = 'other' and lower(coalesce(s.resume_command, '')) like ?)"); + toolTypeParams.push("chat:unified:%"); + } else if (toolType === "cursor") { + legacyChatClauses.push("(s.tool_type = 'other' and lower(coalesce(s.resume_command, '')) like ?)"); + toolTypeParams.push("chat:cursor:%"); + } else if (toolType === "droid-chat") { + legacyChatClauses.push("(s.tool_type = 'other' and lower(coalesce(s.resume_command, '')) like ?)"); + toolTypeParams.push("chat:droid:%"); + } + } + + if (legacyChatClauses.length > 0) { + toolTypeClauses.push(`(${legacyChatClauses.join(" or ")})`); + } + where.push(`(${toolTypeClauses.join(" or ")})`); + params.push(...toolTypeParams); + } const whereSql = where.length ? `where ${where.join(" and ")}` : ""; const limitSql = limit === null ? "" : "limit ?"; diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index 2673ccc83..05cc6c7f4 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -348,6 +348,7 @@ export function useLaneWorkSessions(laneId: string | null) { const unsubscribe = window.ade.agentChat.onEvent((payload) => { if (!laneId) return; if (!shouldRefreshSessionListForChatEvent(payload)) return; + invalidateSessionListCache(); scheduleBackgroundRefresh(180); }); return unsubscribe; diff --git a/apps/desktop/src/renderer/lib/sessionListCache.ts b/apps/desktop/src/renderer/lib/sessionListCache.ts index 0283db8c4..d443ff45f 100644 --- a/apps/desktop/src/renderer/lib/sessionListCache.ts +++ b/apps/desktop/src/renderer/lib/sessionListCache.ts @@ -18,6 +18,12 @@ 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.filter((toolType) => typeof toolType === "string" && toolType.trim().length > 0)), + ).sort(); + if (toolTypes.length > 0) normalized.toolTypes = toolTypes; + } return normalized; } @@ -27,6 +33,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, }); } @@ -54,11 +61,15 @@ export async function listSessionsCached( ): Promise { const key = options?.projectRoot == null ? cacheKey(args) - : JSON.stringify({ + : (() => { + const normalized = normalizeArgs(args); + return JSON.stringify({ projectRoot: options.projectRoot?.trim() || null, - laneId: normalizeArgs(args).laneId ?? null, - status: normalizeArgs(args).status ?? null, + laneId: normalized.laneId ?? null, + status: normalized.status ?? null, + toolTypes: normalized.toolTypes ?? null, }); + })(); const ttlMs = options?.ttlMs ?? DEFAULT_SESSION_LIST_TTL_MS; const limit = requestedLimit(args); const now = Date.now(); 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 = { From ba3f0628d564530699f605cad0d72cadca8983fb Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 31 May 2026 14:48:52 -0400 Subject: [PATCH 03/13] docs(perf): codify lanes roster and chat caps --- .agents/skills/ade-perf-lanes/SKILL.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.agents/skills/ade-perf-lanes/SKILL.md b/.agents/skills/ade-perf-lanes/SKILL.md index a681ace02..a7a4f6ba2 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.1 status: active --- @@ -89,3 +89,15 @@ 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. From daaf051b0ca65aede8586ded3af3c57c07797d2b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 31 May 2026 15:05:30 -0400 Subject: [PATCH 04/13] perf(lanes): refresh visible PR status opportunistically --- .../components/lanes/LanesPage.test.ts | 63 +++++++++++++++++++ .../renderer/components/lanes/LanesPage.tsx | 57 +++++++++++++++++ .../components/lanes/lanePageModel.ts | 44 +++++++++++++ 3 files changed, 164 insertions(+) diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts index 8cec3e3b9..78678062b 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -13,6 +13,7 @@ import { selectGithubLanePrTag, selectLaneTabPrTag, selectLanePrTag, + selectVisibleLanePrRefreshIds, shouldApplyLaneIdsDeepLink, sortLaneListRows, } from "./lanePageModel"; @@ -609,6 +610,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 5b6675aa7..36dae15c1 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -48,6 +48,7 @@ import { resolveVisibleLaneIds, runLaneDeleteBatchWithConcurrency, selectLanePrTag, + selectVisibleLanePrRefreshIds, selectLaneTabPrTag, shouldApplyLaneIdsDeepLink, sortLaneListRows, @@ -141,6 +142,7 @@ 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 EMPTY_LANE_IDS: string[] = []; function normalizeLaneRuntimePlacement(value: unknown): LaneRuntimePlacement { @@ -197,6 +199,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); @@ -504,6 +520,8 @@ 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 hasActiveLaneRuntimeRef = useRef(false); const [autoRebaseEnabled, setAutoRebaseEnabled] = useState(false); const [rebaseSuggestionError, setRebaseSuggestionError] = useState(null); @@ -1109,6 +1127,45 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { }); }, [active, refreshLanePrTags, refreshLaneGithubPrTags]); + 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; + lanePrTagsRequestRef.current += 1; + 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]); + useEffect(() => { if (!active) return; let timer: ReturnType | null = null; diff --git a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts index d22480bd9..c65229b5b 100644 --- a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts +++ b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts @@ -24,6 +24,9 @@ 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 function resolveCreateLaneRequest(args: { name: string; createMode: CreateLaneMode; @@ -322,6 +325,47 @@ 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; +} + type LaneRuntimeBucket = LaneListSnapshot["runtime"]["bucket"]; export function sortLaneListRows>(args: { From 8e8bcde4fdb24da8a380aa85cbdadade690c1960 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 31 May 2026 15:10:33 -0400 Subject: [PATCH 05/13] perf(lanes): pause minimized git actions panes --- .../components/lanes/CommitTimeline.test.tsx | 48 ++++++++++++++++++ .../components/lanes/CommitTimeline.tsx | 6 ++- .../lanes/LaneGitActionsPane.test.tsx | 49 ++++++++++++++++++- .../components/lanes/LaneGitActionsPane.tsx | 18 ++++--- .../components/lanes/LanesPage.test.ts | 26 ++++++++++ .../renderer/components/lanes/LanesPage.tsx | 28 +++++++++-- .../components/lanes/lanePageModel.ts | 16 ++++++ .../components/ui/PaneTilingLayout.tsx | 7 ++- 8 files changed, 183 insertions(+), 15 deletions(-) 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({ diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 36dae15c1..53b53a312 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, @@ -183,13 +184,28 @@ 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); + + useEffect(() => { + 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 { @@ -3015,6 +3031,9 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { expandedGitActionsLaneId, surface, }); + const gitActionsDelayMs = surface === "inline" + ? getDeferredLanePaneDelayMs({ laneId, visibleLaneIds }) + : 0; return { "git-actions": { title: "Git Actions", @@ -3043,10 +3062,12 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { ), bodyClassName: "overflow-hidden", - children: mountGitActionsPane ? ( - + children: null, + renderChildren: ({ minimized }: { minimized: boolean }) => mountGitActionsPane ? ( + >(args: { 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} ); } From d4b457d303a74e3c79d4a1c74316bb8d0e1b5af8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 31 May 2026 15:14:39 -0400 Subject: [PATCH 06/13] docs(perf): codify lanes PR and git action throttles --- .agents/skills/ade-perf-lanes/SKILL.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.agents/skills/ade-perf-lanes/SKILL.md b/.agents/skills/ade-perf-lanes/SKILL.md index a7a4f6ba2..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.1 + version: 0.2.2 status: active --- @@ -101,3 +101,15 @@ Use this as engineering guidance for keeping the Lanes tab fast while adding fea - **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%. From 709e1c76b28a14d6679821eb3f17e0f19555e9c8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 31 May 2026 15:49:13 -0400 Subject: [PATCH 07/13] perf(lanes): unmount hidden work panes Minimized Work lane switch local-runtime callAction dropped 22 -> 16 calls and 2230ms -> 1545ms summed duration in lanes-20260531-compare-candidates-2. --- .../renderer/components/lanes/LanesPage.tsx | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 53b53a312..11840e954 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -3092,26 +3092,30 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { bodyClassName: "overflow-hidden", dataTour: "lanes.workPane", hideHeaderWhenExpanded: true, - children: ( - - { - 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; + }, }, }; }, [ @@ -3119,6 +3123,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { laneSnapshotByLaneId, linearIssueChatContextRequest, expandedGitActionsLaneId, + expandedLaneId, visibleLaneIds, autoRebaseEnabled, openAutoRebaseSettings, From 6e5c28b1bc6d1d9cba1c883fb52772c2a3761045 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 31 May 2026 15:55:09 -0400 Subject: [PATCH 08/13] perf(sessions): split chat tool filters CLI-heavy benchmark with 50k shell sessions and 20 chat sessions improved chat session query time from 11.6ms to 0.079ms per call by using lane/tool indexes and separating legacy fallback rows. --- .../main/services/sessions/sessionService.ts | 81 ++++++++++++------- apps/desktop/src/main/services/state/kvDb.ts | 2 + 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index ecefbd920..4527a660c 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -342,6 +342,7 @@ export function createSessionService({ db }: { db: AdeDb }) { 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 = ?"); @@ -352,55 +353,73 @@ export function createSessionService({ db }: { db: AdeDb }) { 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 + ); + }; + if (normalizedToolTypes.length > 0) { - const directPlaceholders = normalizedToolTypes.map(() => "?").join(", "); - const toolTypeClauses = [`s.tool_type in (${directPlaceholders})`]; - const toolTypeParams: (string | number | null)[] = [...normalizedToolTypes]; const legacyChatClauses: string[] = []; + const legacyChatParams: (string | number | null)[] = []; for (const toolType of normalizedToolTypes) { if (toolType === "codex-chat") { legacyChatClauses.push( - "(s.tool_type = 'other' and (lower(coalesce(s.resume_command, '')) = ? or lower(coalesce(s.resume_command, '')) like ?))", + "(lower(coalesce(s.resume_command, '')) = ? or lower(coalesce(s.resume_command, '')) like ?)", ); - toolTypeParams.push("chat:codex", "chat:codex:%"); + legacyChatParams.push("chat:codex", "chat:codex:%"); } else if (toolType === "claude-chat") { - legacyChatClauses.push("(s.tool_type = 'other' and lower(coalesce(s.resume_command, '')) like ?)"); - toolTypeParams.push("chat:claude:%"); + legacyChatClauses.push("lower(coalesce(s.resume_command, '')) like ?"); + legacyChatParams.push("chat:claude:%"); } else if (toolType === "opencode-chat") { - legacyChatClauses.push("(s.tool_type = 'other' and lower(coalesce(s.resume_command, '')) like ?)"); - toolTypeParams.push("chat:unified:%"); + legacyChatClauses.push("lower(coalesce(s.resume_command, '')) like ?"); + legacyChatParams.push("chat:unified:%"); } else if (toolType === "cursor") { - legacyChatClauses.push("(s.tool_type = 'other' and lower(coalesce(s.resume_command, '')) like ?)"); - toolTypeParams.push("chat:cursor:%"); + legacyChatClauses.push("lower(coalesce(s.resume_command, '')) like ?"); + legacyChatParams.push("chat:cursor:%"); } else if (toolType === "droid-chat") { - legacyChatClauses.push("(s.tool_type = 'other' and lower(coalesce(s.resume_command, '')) like ?)"); - toolTypeParams.push("chat:droid:%"); + 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) { - toolTypeClauses.push(`(${legacyChatClauses.join(" or ")})`); + for (const row of fetchRows(["s.tool_type = 'other'", `(${legacyChatClauses.join(" or ")})`], legacyChatParams)) { + rowsById.set(row.id, row); + } } - where.push(`(${toolTypeClauses.join(" or ")})`); - params.push(...toolTypeParams); + + 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 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 - ); + 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"); From 8792875629ee988cd6315e09da1126ca42113a94 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:40:47 -0400 Subject: [PATCH 09/13] perf(lanes): adapt session refresh under load --- README.md | 13 ++ .../src/main/services/ipc/registerIpc.ts | 220 ++++++++++++++++++ .../localRuntimeConnectionPool.ts | 18 ++ .../src/main/services/pty/ptyService.ts | 140 ++++++++++- apps/desktop/src/preload/global.d.ts | 2 + apps/desktop/src/preload/preload.ts | 3 + apps/desktop/src/renderer/browserMock.ts | 16 ++ .../renderer/components/app/TopBar.test.tsx | 48 ++++ .../src/renderer/components/app/TopBar.tsx | 87 +++++++ .../renderer/components/lanes/LanesPage.tsx | 37 ++- .../lanes/useLaneWorkSessions.test.ts | 169 ++++++++++++++ .../components/lanes/useLaneWorkSessions.ts | 61 ++++- .../terminals/TerminalView.test.tsx | 21 ++ .../components/terminals/TerminalView.tsx | 52 ++++- .../terminals/WorkViewArea.test.tsx | 114 ++++++++- .../components/terminals/WorkViewArea.tsx | 211 ++++++++++++++++- .../terminals/useWorkSessions.test.ts | 49 ++++ .../components/terminals/useWorkSessions.ts | 35 ++- .../src/renderer/lib/resourcePressure.ts | 95 ++++++++ .../src/renderer/lib/sessionListCache.test.ts | 23 ++ .../src/renderer/lib/sessionListCache.ts | 24 +- apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/core.ts | 20 ++ 23 files changed, 1411 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/resourcePressure.ts 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/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index e95bc2888..b7b94336b 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,6 +553,7 @@ 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 { sampleProcessTreeResourceUsage } from "../pty/ptyService"; import type { createPtyService } from "../pty/ptyService"; import { type createDiffService, @@ -657,6 +660,213 @@ 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: { + ptyService?: ReturnType | null; + sessionService?: ReturnType | null; + localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null; + processRegistry?: ProcessRegistryService | null; + sampledAtMs: number; + snapshot: AppResourceUsageSnapshot; +} | null = null; + +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 getRuntimeOwnedPtyUsage( + sessionService?: ReturnType | null, + localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null, + processRegistry?: ProcessRegistryService | null, +): PtyProcessResourceUsageSnapshot { + 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) + : []; + const ownerPids = Array.from(new Set( + [ + ...runningSessions.map((session) => session.ownerPid), + ...(localRuntimeConnectionPool?.getRuntimeProcessIds?.() ?? []), + ].filter((pid): pid is number => typeof pid === "number" && Number.isFinite(pid) && pid > 0 && pid !== process.pid), + )); + return sampleProcessTreeResourceUsage(ownerPids, runningSessions.length); +} + +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( + ptyService?: ReturnType | null, + sessionService?: ReturnType | null, + localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null, + processRegistry?: ProcessRegistryService | null, +): AppResourceUsageSnapshot { + const metrics = app.getAppMetrics(); + const mainMetrics = metrics.filter(isMainProcessMetric); + const rendererMetrics = metrics.filter(isRendererProcessMetric); + const ptyUsage = combinePtyResourceUsage( + ptyService?.getResourceUsageSnapshot?.() ?? emptyPtyResourceUsage(), + getRuntimeOwnedPtyUsage(sessionService, localRuntimeConnectionPool, processRegistry), + ); + 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 getCachedAppResourceUsageSnapshot( + ptyService?: ReturnType | null, + sessionService?: ReturnType | null, + localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null, + processRegistry?: ProcessRegistryService | null, +): AppResourceUsageSnapshot { + const now = Date.now(); + if ( + appResourceUsageCache + && appResourceUsageCache.ptyService === ptyService + && appResourceUsageCache.sessionService === sessionService + && appResourceUsageCache.localRuntimeConnectionPool === localRuntimeConnectionPool + && appResourceUsageCache.processRegistry === processRegistry + && now - appResourceUsageCache.sampledAtMs < APP_RESOURCE_USAGE_CACHE_MS + ) { + return appResourceUsageCache.snapshot; + } + const snapshot = getAppResourceUsageSnapshot(ptyService, sessionService, localRuntimeConnectionPool, processRegistry); + appResourceUsageCache = { + ptyService, + sessionService, + localRuntimeConnectionPool, + processRegistry, + sampledAtMs: now, + snapshot, + }; + return snapshot; +} + export type AppContext = { db: AdeDb | null; logger: Logger; @@ -3317,6 +3527,16 @@ export function registerIpc({ }; }); + ipcMain.handle(IPC.appGetResourceUsage, async (): Promise => { + const ctx = getCtx(); + return getCachedAppResourceUsageSnapshot( + ctx.ptyService, + ctx.sessionService, + localRuntimeConnectionPool, + ctx.processRegistry, + ); + }); + 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.ts b/apps/desktop/src/main/services/pty/ptyService.ts index e58e7f80a..6a96912b9 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,132 @@ const AGENT_CLI_READY_QUIET_MS = 600; const PTY_PROCESS_TREE_KILL_DELAY_MS = 1500; const PTY_PROCESS_TREE_MAX_DEPTH = 12; +type ProcessMetricRow = { + pid: number; + ppid: number; + cpuPercent: number; + rssKB: number; +}; + +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; +} + +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, +): PtyProcessResourceUsageSnapshot { + if (activePtyCount === 0 && rootPids.length === 0) { + return { + activePtyCount: 0, + ptyProcessCount: 0, + ptyCpuPercent: 0, + ptyMemoryMB: 0, + }; + } + + const rows = readProcessMetricRows(); + 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 +1121,15 @@ export function createPtyService({ const ownerPid = processRegistry?.pid ?? null; const ownerProcessStartedAt = processRegistry?.startedAt ?? null; + const getResourceUsageSnapshot = (): 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); + }; + const isOwnedByLivePeerRuntime = (session: { ownerPid?: number | null; ownerProcessStartedAt?: string | null; @@ -4712,6 +4848,8 @@ export function createPtyService({ return false; }, + getResourceUsageSnapshot, + onData(listener: PtyDataListener): () => void { dataListeners.add(listener); return () => { 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/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 11840e954..338b43f6f 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -77,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, @@ -144,6 +145,8 @@ 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 { @@ -1171,7 +1174,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { .then((refreshed) => { if ((appStore.getState().project?.rootPath ?? null) !== startedRoot) return; if (refreshed.length === 0) return; - lanePrTagsRequestRef.current += 1; setLanePrTags((current) => mergePrSummariesById(current, refreshed)); }) .catch(() => { @@ -1184,7 +1186,8 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { useEffect(() => { if (!active) return; - let timer: ReturnType | null = null; + let lifecycleTimer: ReturnType | null = null; + let dataTimer: ReturnType | null = null; const refreshRuntimeOnly = () => refreshLanes({ includeStatus: false, @@ -1193,31 +1196,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 { diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts index b578f11a9..b6524c50a 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, projectRoot: "/fake/project" }, + ); + }); + + 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, projectRoot: "/fake/project" }, + ); + }); + + 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, projectRoot: "/fake/project" }, + ); + }); + + 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, projectRoot: "/fake/project" }, + ); + }); + // ----------------------------------------------------------------------- // launchPtySession: focus/open immediately; refresh reconciles in background. // ----------------------------------------------------------------------- diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index 05cc6c7f4..3719a07ac 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( @@ -273,10 +275,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 +316,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 +337,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 +355,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,26 +365,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; - invalidateSessionListCache(); - 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..d0b07de47 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,7 +655,11 @@ 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 } @@ -2273,6 +2300,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..3b5bc208b 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,7 @@ import { buildWorkSessionTilingTree, type TilingPreset } from "./workSessionTili import { laneSurfaceTint } from "../lanes/laneDesignTokens"; import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; +import { appResourcePressureLevel, getAppResourceUsageCoalesced } from "../../lib/resourcePressure"; function isSessionAwaitingInput(session: TerminalSessionSummary): boolean { return sessionNeedsChatTabHighlight({ @@ -82,6 +84,194 @@ function isAgentCliSession(session: TerminalSessionSummary): boolean { ); } +type GridTerminalPressureLevel = 0 | 1 | 2 | 3 | 4; + +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 clampPressureLevel(value: number): GridTerminalPressureLevel { + return Math.max(0, Math.min(4, Math.round(value))) as GridTerminalPressureLevel; +} + +function pressureLevelForThresholds( + value: number | null | undefined, + thresholds: [number, number, number, number], +): GridTerminalPressureLevel { + 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; +} + +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 +1525,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 +1548,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 +1618,7 @@ export function WorkViewArea({ isActive={isActive} pageActive={pageActive} shouldAutofocus={isActive} - terminalVisible + terminalVisible={terminalVisible} layoutVariant="grid-tile" onInfoClick={onInfoClick} onContextMenu={onContextMenu} @@ -1435,6 +1635,8 @@ export function WorkViewArea({ ), [ activeItemId, closingPtyIds, + gridTerminalRefreshPolicy, + gridTerminalRefreshPulse, handleContextMenu, lanes, laneColorById, @@ -1697,7 +1899,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; }); @@ -1420,6 +1429,46 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { 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 }, + { projectRoot: "/fake/project" }, + ); + }); + it("refetches visible Work when the window regains focus", async () => { renderHook(() => useWorkSessions()); diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index cd1c8298c..2fb568113 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); @@ -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/lib/resourcePressure.ts b/apps/desktop/src/renderer/lib/resourcePressure.ts new file mode 100644 index 000000000..349d29734 --- /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; +} + +function clampPressureLevel(value: number): ResourcePressureLevel { + if (!Number.isFinite(value) || value <= 0) return 0; + if (value >= 4) return 4; + return Math.trunc(value) as ResourcePressureLevel; +} + +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 || !usage.totalMemoryMB || 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..7f3e17b1a 100644 --- a/apps/desktop/src/renderer/lib/sessionListCache.test.ts +++ b/apps/desktop/src/renderer/lib/sessionListCache.test.ts @@ -136,4 +136,27 @@ 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 }, { projectRoot: "/project/a" }); + await listSessionsCached({ laneId: "lane-2", limit: 3 }, { projectRoot: "/project/a" }); + await listSessionsCached({ laneId: "lane-1", limit: 4 }, { projectRoot: "/project/b" }); + + invalidateSessionListCache({ projectRoot: "/project/a", laneId: "lane-1" }); + + listMock.mockResolvedValueOnce(makeRows(5)); + const invalidated = await listSessionsCached({ laneId: "lane-1", limit: 2 }, { projectRoot: "/project/a" }); + const sameProjectOtherLane = await listSessionsCached({ laneId: "lane-2", limit: 3 }, { projectRoot: "/project/a" }); + const sameLaneOtherProject = await listSessionsCached({ laneId: "lane-1", limit: 4 }, { projectRoot: "/project/b" }); + + expect(invalidated).toHaveLength(2); + expect(sameProjectOtherLane).toHaveLength(3); + expect(sameLaneOtherProject).toHaveLength(4); + expect(listMock).toHaveBeenCalledTimes(4); + }); }); diff --git a/apps/desktop/src/renderer/lib/sessionListCache.ts b/apps/desktop/src/renderer/lib/sessionListCache.ts index d443ff45f..f50c040cc 100644 --- a/apps/desktop/src/renderer/lib/sessionListCache.ts +++ b/apps/desktop/src/renderer/lib/sessionListCache.ts @@ -11,6 +11,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 {}; @@ -122,8 +126,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; From 087766d62c085888b726f69eb93ade2fb141bbfe Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:54:19 -0400 Subject: [PATCH 10/13] fix(lanes): share resource usage sampling --- .../src/main/services/ipc/registerIpc.ts | 20 ++++++-- .../src/main/services/pty/ptyService.test.ts | 46 ++++++++++++++++++ .../src/main/services/pty/ptyService.ts | 13 +++-- .../components/terminals/WorkViewArea.tsx | 26 ++++------ .../src/renderer/lib/resourcePressure.test.ts | 47 +++++++++++++++++++ .../src/renderer/lib/resourcePressure.ts | 6 +-- 6 files changed, 127 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/resourcePressure.test.ts diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index b7b94336b..e0814276e 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -553,8 +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 { sampleProcessTreeResourceUsage } from "../pty/ptyService"; -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, @@ -747,6 +747,7 @@ function getRuntimeOwnedPtyUsage( sessionService?: ReturnType | null, localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null, processRegistry?: ProcessRegistryService | null, + readRows?: ProcessMetricRowsProvider, ): PtyProcessResourceUsageSnapshot { const isLiveSessionOwner = (session: { ownerPid?: number | null; @@ -772,7 +773,15 @@ function getRuntimeOwnedPtyUsage( ...(localRuntimeConnectionPool?.getRuntimeProcessIds?.() ?? []), ].filter((pid): pid is number => typeof pid === "number" && Number.isFinite(pid) && pid > 0 && pid !== process.pid), )); - return sampleProcessTreeResourceUsage(ownerPids, runningSessions.length); + return sampleProcessTreeResourceUsage(ownerPids, runningSessions.length, readRows); +} + +function createSharedProcessMetricRowsProvider(): ProcessMetricRowsProvider { + let rows: ReturnType | undefined; + return () => { + if (rows === undefined) rows = readProcessMetricRows(); + return rows; + }; } function processMetricKind(metric: ElectronProcessMetric): string { @@ -816,9 +825,10 @@ function getAppResourceUsageSnapshot( const metrics = app.getAppMetrics(); const mainMetrics = metrics.filter(isMainProcessMetric); const rendererMetrics = metrics.filter(isRendererProcessMetric); + const readRows = createSharedProcessMetricRowsProvider(); const ptyUsage = combinePtyResourceUsage( - ptyService?.getResourceUsageSnapshot?.() ?? emptyPtyResourceUsage(), - getRuntimeOwnedPtyUsage(sessionService, localRuntimeConnectionPool, processRegistry), + ptyService?.getResourceUsageSnapshot?.(readRows) ?? emptyPtyResourceUsage(), + getRuntimeOwnedPtyUsage(sessionService, localRuntimeConnectionPool, processRegistry, readRows), ); const electronCpuPercent = sumMetricCpu(metrics); const electronMemoryMB = sumMetricMemoryMB(metrics); 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 6a96912b9..6e969e7de 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -141,20 +141,22 @@ const AGENT_CLI_READY_QUIET_MS = 600; const PTY_PROCESS_TREE_KILL_DELAY_MS = 1500; const PTY_PROCESS_TREE_MAX_DEPTH = 12; -type ProcessMetricRow = { +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; } -function readProcessMetricRows(): ProcessMetricRow[] | null { +export function readProcessMetricRows(): ProcessMetricRow[] | null { try { const result = spawnSync("ps", ["-axo", "pid=,ppid=,pcpu=,rss="], { encoding: "utf8", @@ -226,6 +228,7 @@ function collectProcessTreePids( export function sampleProcessTreeResourceUsage( rootPids: number[], activePtyCount = rootPids.length, + readRows: ProcessMetricRowsProvider = readProcessMetricRows, ): PtyProcessResourceUsageSnapshot { if (activePtyCount === 0 && rootPids.length === 0) { return { @@ -236,7 +239,7 @@ export function sampleProcessTreeResourceUsage( }; } - const rows = readProcessMetricRows(); + const rows = readRows(); if (!rows) { return { activePtyCount, @@ -1121,13 +1124,13 @@ export function createPtyService({ const ownerPid = processRegistry?.pid ?? null; const ownerProcessStartedAt = processRegistry?.startedAt ?? null; - const getResourceUsageSnapshot = (): PtyProcessResourceUsageSnapshot => { + 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); + return sampleProcessTreeResourceUsage(rootPids, activePtyCount, readRows); }; const isOwnedByLivePeerRuntime = (session: { diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 3b5bc208b..b194df093 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -54,7 +54,13 @@ import { buildWorkSessionTilingTree, type TilingPreset } from "./workSessionTili import { laneSurfaceTint } from "../lanes/laneDesignTokens"; import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; -import { appResourcePressureLevel, getAppResourceUsageCoalesced } from "../../lib/resourcePressure"; +import { + appResourcePressureLevel, + clampPressureLevel, + getAppResourceUsageCoalesced, + pressureLevelForThresholds, + type ResourcePressureLevel, +} from "../../lib/resourcePressure"; function isSessionAwaitingInput(session: TerminalSessionSummary): boolean { return sessionNeedsChatTabHighlight({ @@ -84,7 +90,7 @@ function isAgentCliSession(session: TerminalSessionSummary): boolean { ); } -type GridTerminalPressureLevel = 0 | 1 | 2 | 3 | 4; +type GridTerminalPressureLevel = ResourcePressureLevel; type GridTerminalRefreshPolicy = { level: GridTerminalPressureLevel; @@ -100,22 +106,6 @@ const NORMAL_GRID_TERMINAL_REFRESH_POLICY: GridTerminalRefreshPolicy = { pulseMs: 0, }; -function clampPressureLevel(value: number): GridTerminalPressureLevel { - return Math.max(0, Math.min(4, Math.round(value))) as GridTerminalPressureLevel; -} - -function pressureLevelForThresholds( - value: number | null | undefined, - thresholds: [number, number, number, number], -): GridTerminalPressureLevel { - 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; -} - function readRendererHeapRatio(): number | null { const perf = performance as Performance & { memory?: { 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 index 349d29734..d1a1c03a9 100644 --- a/apps/desktop/src/renderer/lib/resourcePressure.ts +++ b/apps/desktop/src/renderer/lib/resourcePressure.ts @@ -18,13 +18,13 @@ export function getAppResourceUsageCoalesced(): Promise= 4) return 4; return Math.trunc(value) as ResourcePressureLevel; } -function pressureLevelForThresholds( +export function pressureLevelForThresholds( value: number | null | undefined, thresholds: [number, number, number, number], ): ResourcePressureLevel { @@ -73,7 +73,7 @@ function formatMemory(value: number | null | undefined): string | null { } function formatSystemMemory(usage: AppResourceUsageSnapshot | null): string | null { - if (!usage?.freeMemoryMB || !usage.totalMemoryMB || usage.totalMemoryMB <= 0) return null; + if (usage?.freeMemoryMB == null || !usage.totalMemoryMB || 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`; From 6eb80934be210141226b365996e0b008fade5ba5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:02:24 -0400 Subject: [PATCH 11/13] fix(lanes): address refresh review gaps --- apps/desktop/src/main/main.ts | 5 ++ .../src/main/services/ipc/registerIpc.ts | 88 ++++++++++++------- .../services/sessions/sessionService.test.ts | 13 ++- .../renderer/components/lanes/LanesPage.tsx | 28 +++++- .../src/renderer/lib/resourcePressure.ts | 2 +- .../src/renderer/lib/sessionListCache.test.ts | 10 +++ .../src/renderer/lib/sessionListCache.ts | 8 +- 7 files changed, 118 insertions(+), 36 deletions(-) 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/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index e0814276e..6295c4760 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -663,14 +663,14 @@ import { deleteMacosVmFromProjectState } from "../macosVm/macosVmRecovery"; type ElectronProcessMetric = ReturnType[number]; const APP_RESOURCE_USAGE_CACHE_MS = 900; let appResourceUsageCache: { - ptyService?: ReturnType | null; - sessionService?: ReturnType | null; + contexts: AppResourceUsageContext[]; localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null; - processRegistry?: ProcessRegistryService | 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; @@ -743,12 +743,10 @@ function combinePtyResourceUsage( }; } -function getRuntimeOwnedPtyUsage( +function collectRuntimeOwnedPtyRoots( sessionService?: ReturnType | null, - localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null, processRegistry?: ProcessRegistryService | null, - readRows?: ProcessMetricRowsProvider, -): PtyProcessResourceUsageSnapshot { +): { activePtyCount: number; ownerPids: number[] } { const isLiveSessionOwner = (session: { ownerPid?: number | null; ownerProcessStartedAt?: string | null; @@ -767,13 +765,32 @@ function getRuntimeOwnedPtyUsage( .list({ status: "running", limit: null }) .filter(isLiveSessionOwner) : []; - const ownerPids = Array.from(new Set( - [ - ...runningSessions.map((session) => session.ownerPid), - ...(localRuntimeConnectionPool?.getRuntimeProcessIds?.() ?? []), - ].filter((pid): pid is number => typeof pid === "number" && Number.isFinite(pid) && pid > 0 && pid !== process.pid), - )); - return sampleProcessTreeResourceUsage(ownerPids, runningSessions.length, readRows); + 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 { @@ -817,18 +834,23 @@ function getSystemMemoryMB(): { freeMemoryMB: number | null; totalMemoryMB: numb } function getAppResourceUsageSnapshot( - ptyService?: ReturnType | null, - sessionService?: ReturnType | null, + contexts: AppResourceUsageContext[], localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null, - processRegistry?: ProcessRegistryService | 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( - ptyService?.getResourceUsageSnapshot?.(readRows) ?? emptyPtyResourceUsage(), - getRuntimeOwnedPtyUsage(sessionService, localRuntimeConnectionPool, processRegistry, readRows), + ptyServiceUsage, + getRuntimeOwnedPtyUsage(contexts, localRuntimeConnectionPool, readRows), ); const electronCpuPercent = sumMetricCpu(metrics); const electronMemoryMB = sumMetricMemoryMB(metrics); @@ -848,29 +870,30 @@ function getAppResourceUsageSnapshot( }; } +function sameAppResourceUsageContexts( + first: AppResourceUsageContext[], + second: AppResourceUsageContext[], +): boolean { + return first.length === second.length && first.every((ctx, index) => ctx === second[index]); +} + function getCachedAppResourceUsageSnapshot( - ptyService?: ReturnType | null, - sessionService?: ReturnType | null, + contexts: AppResourceUsageContext[], localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null, - processRegistry?: ProcessRegistryService | null, ): AppResourceUsageSnapshot { const now = Date.now(); if ( appResourceUsageCache - && appResourceUsageCache.ptyService === ptyService - && appResourceUsageCache.sessionService === sessionService + && sameAppResourceUsageContexts(appResourceUsageCache.contexts, contexts) && appResourceUsageCache.localRuntimeConnectionPool === localRuntimeConnectionPool - && appResourceUsageCache.processRegistry === processRegistry && now - appResourceUsageCache.sampledAtMs < APP_RESOURCE_USAGE_CACHE_MS ) { return appResourceUsageCache.snapshot; } - const snapshot = getAppResourceUsageSnapshot(ptyService, sessionService, localRuntimeConnectionPool, processRegistry); + const snapshot = getAppResourceUsageSnapshot(contexts, localRuntimeConnectionPool); appResourceUsageCache = { - ptyService, - sessionService, + contexts: [...contexts], localRuntimeConnectionPool, - processRegistry, sampledAtMs: now, snapshot, }; @@ -1519,6 +1542,7 @@ function buildIssueResolutionInstructionsFromThread(arg: LaunchPrIssueResolution export function registerIpc({ getCtx, + getResourceUsageContexts, getSyncService, resolveSyncService, runWithIpcWindow, @@ -1535,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; @@ -3539,11 +3564,10 @@ export function registerIpc({ ipcMain.handle(IPC.appGetResourceUsage, async (): Promise => { const ctx = getCtx(); + const contexts = getResourceUsageContexts?.() ?? [ctx]; return getCachedAppResourceUsageSnapshot( - ctx.ptyService, - ctx.sessionService, + contexts.length > 0 ? contexts : [ctx], localRuntimeConnectionPool, - ctx.processRegistry, ); }); diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 189d078fd..3a71633a0 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -492,7 +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"); - expect(service.list({ laneId: "lane-1", toolTypes: ["droid-chat"] })[0]?.id).toBe("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/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 338b43f6f..545a9fb67 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -197,8 +197,15 @@ function DeferredLanePane({ delayMs?: number; }) { 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; @@ -541,6 +548,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { 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); @@ -1146,6 +1154,16 @@ 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) { @@ -1182,7 +1200,15 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { }, LANE_VISIBLE_PR_REFRESH_DEBOUNCE_MS); return () => window.clearTimeout(timer); - }, [active, appStore, project?.rootPath, visibleLaneIds, lanePrByLaneId, lanePrTags]); + }, [ + active, + appStore, + project?.rootPath, + visibleLaneIds, + lanePrByLaneId, + lanePrTags, + laneVisiblePrRefreshVisibilityToken, + ]); useEffect(() => { if (!active) return; diff --git a/apps/desktop/src/renderer/lib/resourcePressure.ts b/apps/desktop/src/renderer/lib/resourcePressure.ts index d1a1c03a9..d54e50305 100644 --- a/apps/desktop/src/renderer/lib/resourcePressure.ts +++ b/apps/desktop/src/renderer/lib/resourcePressure.ts @@ -73,7 +73,7 @@ function formatMemory(value: number | null | undefined): string | null { } function formatSystemMemory(usage: AppResourceUsageSnapshot | null): string | null { - if (usage?.freeMemoryMB == null || !usage.totalMemoryMB || usage.totalMemoryMB <= 0) return 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`; diff --git a/apps/desktop/src/renderer/lib/sessionListCache.test.ts b/apps/desktop/src/renderer/lib/sessionListCache.test.ts index 7f3e17b1a..1851f5c3a 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) => { diff --git a/apps/desktop/src/renderer/lib/sessionListCache.ts b/apps/desktop/src/renderer/lib/sessionListCache.ts index f50c040cc..943342b03 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; @@ -24,7 +26,11 @@ function normalizeArgs(args?: ListSessionsArgs): ListSessionsArgs { 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.filter((toolType) => typeof toolType === "string" && toolType.trim().length > 0)), + new Set( + args.toolTypes + .map((toolType) => toolType.trim()) + .filter((toolType): toolType is SessionToolType => toolType.length > 0), + ), ).sort(); if (toolTypes.length > 0) normalized.toolTypes = toolTypes; } From a5bcde3f259ded0fe3d6aca7cac849fe6db8f386 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:07:44 -0400 Subject: [PATCH 12/13] fix(sessions): align cache scope with active project --- .../main/services/chat/agentChatService.ts | 15 +++----- .../components/graph/WorkspaceGraphPage.tsx | 3 +- .../renderer/components/lanes/laneAgents.ts | 2 +- .../components/lanes/useLaneWorkSessions.ts | 2 +- .../components/terminals/TerminalView.tsx | 6 +++- .../components/terminals/useWorkSessions.ts | 4 +-- .../src/renderer/lib/sessionListCache.test.ts | 36 +++++++++++++++---- .../src/renderer/lib/sessionListCache.ts | 14 ++------ 8 files changed, 47 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 95c6b2f8e..147538329 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -2548,24 +2548,19 @@ function describeCodexModel(value: string): string | null { return null; } -const CHAT_SESSION_TOOL_TYPES: TerminalToolType[] = [ +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 { 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/laneAgents.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.ts index 2734a8d95..bddbb6a2b 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(); diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index 3719a07ac..6b8be722d 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -216,7 +216,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; diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index d0b07de47..fe1c61b43 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -664,7 +664,11 @@ function setRuntimeInteractionState(runtime: CachedRuntime, active: boolean) { // ignore } try { - runtime.host.toggleAttribute("inert", !active); + if (active) { + runtime.host.removeAttribute("inert"); + } else { + runtime.host.setAttribute("inert", ""); + } } catch { // ignore } diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index 2fb568113..8c08c748a 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -772,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) { diff --git a/apps/desktop/src/renderer/lib/sessionListCache.test.ts b/apps/desktop/src/renderer/lib/sessionListCache.test.ts index 1851f5c3a..1bebb09a3 100644 --- a/apps/desktop/src/renderer/lib/sessionListCache.test.ts +++ b/apps/desktop/src/renderer/lib/sessionListCache.test.ts @@ -153,20 +153,44 @@ describe("sessionListCache", () => { .mockResolvedValueOnce(makeRows(3)) .mockResolvedValueOnce(makeRows(4)); - await listSessionsCached({ laneId: "lane-1", limit: 2 }, { projectRoot: "/project/a" }); - await listSessionsCached({ laneId: "lane-2", limit: 3 }, { projectRoot: "/project/a" }); - await listSessionsCached({ laneId: "lane-1", limit: 4 }, { projectRoot: "/project/b" }); + 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)); - const invalidated = await listSessionsCached({ laneId: "lane-1", limit: 2 }, { projectRoot: "/project/a" }); - const sameProjectOtherLane = await listSessionsCached({ laneId: "lane-2", limit: 3 }, { projectRoot: "/project/a" }); - const sameLaneOtherProject = await listSessionsCached({ laneId: "lane-1", limit: 4 }, { projectRoot: "/project/b" }); + 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 943342b03..6c566e5a1 100644 --- a/apps/desktop/src/renderer/lib/sessionListCache.ts +++ b/apps/desktop/src/renderer/lib/sessionListCache.ts @@ -67,19 +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) - : (() => { - const normalized = normalizeArgs(args); - return JSON.stringify({ - projectRoot: options.projectRoot?.trim() || null, - laneId: normalized.laneId ?? null, - status: normalized.status ?? null, - toolTypes: normalized.toolTypes ?? null, - }); - })(); + const key = cacheKey(args); const ttlMs = options?.ttlMs ?? DEFAULT_SESSION_LIST_TTL_MS; const limit = requestedLimit(args); const now = Date.now(); From e3796b563df4ccd77ad8b231624fce7836f99495 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:18:46 -0400 Subject: [PATCH 13/13] test(sessions): align refresh cache expectations --- .../renderer/components/lanes/laneAgents.ts | 6 +++-- .../lanes/useLaneWorkSessions.test.ts | 24 +++++++++---------- .../components/lanes/useLaneWorkSessions.ts | 1 - .../terminals/useWorkSessions.test.ts | 15 +++++------- 4 files changed, 22 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.ts index bddbb6a2b..45e84677a 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.ts @@ -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/useLaneWorkSessions.test.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts index b6524c50a..66da5f10a 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts @@ -247,7 +247,7 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { expect(listSessionsCachedMock).toHaveBeenCalledWith( { laneId: "lane-1", limit: 200 }, - { force: false, projectRoot: "/fake/project" }, + { force: false }, ); }); @@ -286,7 +286,7 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { expect(invalidateSessionListCache).toHaveBeenCalledWith({ projectRoot: "/fake/project", laneId: "lane-1" }); expect(listSessionsCachedMock).toHaveBeenCalledWith( { laneId: "lane-1", limit: 200 }, - { force: false, projectRoot: "/fake/project" }, + { force: false }, ); }); @@ -325,7 +325,7 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { expect(invalidateSessionListCache).toHaveBeenCalledWith({ projectRoot: "/fake/project", laneId: "lane-1" }); expect(listSessionsCachedMock).toHaveBeenCalledWith( { laneId: "lane-1", limit: 200 }, - { force: false, projectRoot: "/fake/project" }, + { force: false }, ); }); @@ -364,7 +364,7 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { expect(invalidateSessionListCache).toHaveBeenCalledWith({ projectRoot: "/fake/project", laneId: "lane-1" }); expect(listSessionsCachedMock).toHaveBeenCalledWith( { laneId: "lane-1", limit: 200 }, - { force: false, projectRoot: "/fake/project" }, + { force: false }, ); }); @@ -602,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; }); @@ -626,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(() => { @@ -636,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 6b8be722d..4f689097b 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -183,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; diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index 8a6ff0bca..fe9fe0fc6 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -415,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", @@ -703,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 () => { @@ -1425,7 +1425,7 @@ 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(); }); @@ -1463,10 +1463,7 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { await new Promise((r) => setTimeout(r, 60)); }); - expect(listSessionsCachedMock).toHaveBeenCalledWith( - { limit: 500 }, - { projectRoot: "/fake/project" }, - ); + expect(listSessionsCachedMock).toHaveBeenCalledWith({ limit: 500 }, undefined); }); it("refetches visible Work when the window regains focus", async () => { @@ -1485,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 () => { @@ -1534,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); }); });