From dce2612a65afb42776fddc977fb3d0d4909d6c6e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:43:37 -0400 Subject: [PATCH 1/3] perf: end-to-end optimization pass across desktop main + renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile-driven cleanup of CPU/memory/IPC/render hot paths uncovered while running the Electron app under stress. No product behavior changes — just removing waste and tightening contracts. Main process - aiIntegrationService: trim redundant work and tighten auth detection paths. - authDetector: cache + dedupe lookups; expanded test coverage. - ipc/registerIpc: validation + redaction hardening on the IPC surface. - ptyService: terminal session lifecycle fixes (stale-id handling, prune). - rebaseSuggestionService: cache TTL/LRU + rate-bucket prune. - projectIconResolver: fewer disk hits, deterministic fallbacks. - syncService, memory/embedding services: drop redundant work, fix worker shutdown, add coverage. - cursorModelsDiscovery / droidModelsDiscovery: shared discovery cleanup. Preload / renderer - preload: large reorganization of the IPC bridge surface (~+800 lines). - AppShell, TopBar, TabNav, ChatGitToolbar, AgentChatPane, AgentChatComposer, LanesPage, WorkspaceGraphPage, TerminalView, PaneTilingLayout: avoid avoidable re-renders, polling, log spam. - appStore: state slice cleanup + tests. - terminalAttention: lifecycle correctness. Tooling - Add .claude/commands/optimize.md (the /optimize skill used to drive this pass). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands/optimize.md | 325 +++++++ apps/desktop/src/main/main.ts | 24 +- .../main/services/ai/aiIntegrationService.ts | 325 ++++--- .../src/main/services/ai/authDetector.test.ts | 69 +- .../src/main/services/ai/authDetector.ts | 114 ++- .../services/ai/tools/memoryTools.test.ts | 20 +- .../services/chat/cursorModelsDiscovery.ts | 18 +- .../services/chat/droidModelsDiscovery.ts | 18 +- .../src/main/services/ipc/registerIpc.ts | 183 +++- .../main/services/lanes/autoRebaseService.ts | 3 +- .../lanes/rebaseSuggestionService.test.ts | 38 + .../services/lanes/rebaseSuggestionService.ts | 134 ++- .../services/memory/embeddingService.test.ts | 40 +- .../main/services/memory/embeddingService.ts | 10 +- .../memory/embeddingWorkerService.test.ts | 26 + .../services/memory/embeddingWorkerService.ts | 14 +- .../memory/knowledgeCaptureService.test.ts | 12 + .../memory/knowledgeCaptureService.ts | 8 +- .../src/main/services/memory/memoryService.ts | 9 +- .../services/projects/projectIconResolver.ts | 131 ++- .../src/main/services/pty/ptyService.test.ts | 89 +- .../src/main/services/pty/ptyService.ts | 116 ++- .../src/main/services/sync/syncService.ts | 46 +- apps/desktop/src/preload/global.d.ts | 3 +- apps/desktop/src/preload/preload.ts | 874 ++++++++++++++---- .../src/renderer/components/app/AppShell.tsx | 39 +- .../src/renderer/components/app/TabNav.tsx | 2 +- .../renderer/components/app/TopBar.test.tsx | 2 +- .../src/renderer/components/app/TopBar.tsx | 4 +- .../components/chat/AgentChatComposer.tsx | 36 +- .../components/chat/AgentChatPane.tsx | 50 +- .../components/chat/ChatGitToolbar.tsx | 13 +- .../components/graph/WorkspaceGraphPage.tsx | 21 +- .../renderer/components/lanes/LanesPage.tsx | 28 +- .../terminals/TerminalView.test.tsx | 24 +- .../components/terminals/TerminalView.tsx | 16 +- .../components/ui/PaneTilingLayout.tsx | 13 +- apps/desktop/src/renderer/index.css | 13 +- .../renderer/lib/terminalAttention.test.ts | 4 +- .../src/renderer/lib/terminalAttention.ts | 2 +- .../src/renderer/state/appStore.test.ts | 45 + apps/desktop/src/renderer/state/appStore.ts | 51 +- apps/desktop/src/shared/types/lanes.ts | 3 + apps/desktop/src/shared/types/sync.ts | 10 + 44 files changed, 2449 insertions(+), 576 deletions(-) create mode 100644 .claude/commands/optimize.md diff --git a/.claude/commands/optimize.md b/.claude/commands/optimize.md new file mode 100644 index 000000000..8b5a85059 --- /dev/null +++ b/.claude/commands/optimize.md @@ -0,0 +1,325 @@ +--- +name: optimize +description: Profile a large ADE feature end-to-end, add temporary or permanent telemetry, run the Electron app, find real CPU/memory/IPC/render hot paths, fix them, and verify with stress tests. +--- + +# /optimize — Performance Steward + +You are the performance steward for ADE. Use this after a large feature lands, when the app works but might be too heavy for normal laptops. + +**Argument:** `$ARGUMENTS` — optional feature or surface hint, for example `/optimize Work and Lanes`, `/optimize iOS simulator drawer`, or `/optimize graph route`. + +Your job is not to make the app feel stripped down. Preserve the product's intent and interaction quality while removing waste, polling, avoidable rendering, memory spikes, runaway logs, redundant IPC, and expensive cold-start behavior. + +--- + +## Operating mode + +Run autonomously. Do not stop at a plan. Read, instrument, run, measure, fix, and verify. Ask the user only if a required action would spend money, use an expensive model, destroy data, or cannot be inferred from repo context. + +Be concrete. Every optimization should be backed by at least one of: + +- A live log finding. +- Process CPU/RSS/GPU evidence. +- A renderer/DOM/animation finding. +- A repeated IPC or polling pattern. +- A testable code path that clearly does unnecessary work. + +Do not make speculative cleanup the main result. If you cannot reproduce a suspected issue, leave a short note and move to the next measurable surface. + +--- + +## Phase 0: Understand the surface + +1. Read the relevant docs before editing: + - `AGENTS.md` or the prompt-provided project instructions. + - `docs/ARCHITECTURE.md` if the change crosses app/service boundaries. + - Feature docs under `docs/features/**` that match `$ARGUMENTS`. + - Existing playbooks if the surface involves PRs, lanes, missions, computer use, sync, or release. + +2. Inspect the changed surface: + - `git status --short` + - `git diff --stat` + - `git diff -- ` + - `rg` for the feature's IPC channels, services, hooks, intervals, observers, animations, and route components. + +3. Identify the likely hot paths: + - Renderer route mount and tab switches. + - Work/Lanes session lists and terminal panes. + - IPC polling and fan-out calls. + - Main-process services doing filesystem, git, SQLite, model discovery, sync, or embedding work. + - Hidden drawers or panes that still run effects while closed. + - Infinite CSS animations, WebGL/canvas surfaces, resize loops, observers, and timers. + - Startup, project switch, and route navigation cold paths. + +Keep a working list of surfaces to test. Prefer a small number of realistic flows over broad shallow poking. + +--- + +## Phase 1: Establish observability + +Before making performance edits, make sure the app can tell you what is happening. + +1. Look for existing instrumentation: + - IPC begin/done/summary logs. + - Route change logs. + - Service phase summaries. + - PTY/session output summaries. + - Renderer debug logs. + - Cache hit/miss or model discovery summaries. + +2. If logs are missing, add narrow instrumentation first: + - For IPC handlers: log channel, duration, slow count, failure count, and top callers when available. + - For expensive service methods: log phase timings, input size/counts, cache hits, and result counts. + - For terminal/session output: log chunks, batches, bytes/chars, listener count, active session count. + - For renderer effects: log route mount/unmount or ready state only when the structural signature changes, not every render. + +3. Instrumentation rules: + - Summaries beat per-item spam. + - Redact user prompts, secrets, command input, tokens, and file contents. + - Add logs behind existing logger/debug patterns. + - Avoid permanent noisy logs. If a log is only useful during this run, remove it before finishing or gate it behind an existing dev/debug flag. + +--- + +## Phase 2: Run the app and attach to the real Electron surface + +Use the local desktop app as the source of truth. + +1. Start the dev app from `apps/desktop`: + +```bash +npm run dev +``` + +2. Keep the dev terminal visible. Watch for: + - `dev launcher using http://localhost:5173` + - `DevTools listening on ws://127.0.0.1:9222` + - `window.loading_url` + - `renderer.route_change` + - `ipc.invoke.summary` + - Feature-specific summary logs. + +3. Attach to Electron, not Safari: + - Prefer the `Electron` app entry when using computer-use. + - Confirm the window URL contains `localhost:5173`. + - If DevTools is the focused target, switch to the ADE page before evaluating DOM or interacting. + +4. If using CDP/agent-browser, target the ADE tab: + +```bash +agent-browser --cdp 9222 tab +agent-browser --cdp 9222 tab +``` + +5. Collect baseline process data: + +```bash +pgrep -fl "Electron . --remote-debugging-port=9222|Electron Helper|vite --port 5173|tsup --watch|esbuild --service" +ps -axo pid,ppid,%cpu,%mem,rss,comm,args +``` + +Use process names carefully: + - Main Electron process: app services, SQLite, IPC handlers. + - Renderer helper: React route work, DOM rendering, terminal rendering. + - GPU helper: WebGL/canvas/compositing/animation pressure. + - Network utility: fetch/WebSocket behavior. + +Close extra DevTools targets before trusting memory numbers. DevTools can inflate RSS and CPU. + +--- + +## Phase 3: Navigate and profile the feature + +Run realistic flows with logs open. + +1. Sweep relevant tabs/routes: + - Work + - Lanes + - Files + - Run + - Graph + - PRs + - Review + - History + - Automations + - Missions + - Settings + +If `$ARGUMENTS` names a surface, spend most time there but still check adjacent routes that stay mounted or subscribe to the same data. + +2. For each route, record: + - Cold navigation IPC summary. + - Idle IPC summary after 10-20 seconds. + - Main/renderer/GPU CPU and RSS. + - `document.getAnimations({ subtree: true })`. + - Number of canvases/WebGL/xterm instances if relevant. + - Obvious repeated logs, repeated effects, or repeated identical IPC calls. + +Useful renderer probes: + +```js +JSON.stringify({ + href: location.href, + hidden: document.hidden, + visibility: document.visibilityState, + animations: document.getAnimations({ subtree: true }).map((a) => ({ + state: a.playState, + tag: a.effect?.target?.tagName, + cls: String(a.effect?.target?.className).slice(0, 160), + text: a.effect?.target?.textContent?.slice(0, 80), + })), + xterms: document.querySelectorAll(".xterm").length, + xtermCanvases: document.querySelectorAll(".xterm canvas").length, + canvases: document.querySelectorAll("canvas").length, +}) +``` + +3. Watch for these patterns: + - Same IPC call every second or every render. + - Multiple identical IPC calls during mount. + - A route refreshing full decorated snapshots when it only needs counts. + - Hidden panels polling, probing devices, or reading files. + - Model discovery or provider probing blocking composer open. + - Large transcript reads on route mount. + - Fit/resize loops in terminals. + - Infinite status animations keeping GPU/compositor awake. + - WebGL/canvas rendering where DOM/static rendering is enough. + - Cache misses for data that changes rarely, like project icons, auth status, model inventories, or GitHub status. + +--- + +## Phase 4: Stress the real workflow + +For ADE, always stress Work and Lanes unless the feature is completely unrelated. Most users live there. + +1. Work tab stress: + - Open or reuse a lane. + - Create a shell session and run a bounded output stream. + - Keep the terminal visible so renderer cost is real. + +Example shell stress: + +```bash +node -e 'let i=0; const t=setInterval(()=>{process.stdout.write("ade-stress "+(++i)+" abcdefghijklmnopqrstuvwxyz0123456789\n"); if(i>=3000){clearInterval(t); process.exit(0)}},2)' +``` + +2. Lanes tab stress: + - Navigate to Lanes while a session is running or immediately after heavy terminal output. + - Observe whether Lanes does full status snapshots, rebase suggestions, git/diff reads, or presence updates repeatedly. + - Confirm idle logs calm down. + +3. Chat stress: + - Prefer a cheap model only: Haiku, a mini Codex/OpenAI model, or the cheapest available local/dev model. + - Do not use expensive models for performance testing. + - If cheap model availability cannot be confirmed, use shell/session stress instead and note why. + - Start multiple chats only when the user explicitly asked for multi-agent load or the feature depends on parallel chats. + +4. Computer-use/iOS/simulator stress: + - Only stress if the feature touches those panels. + - Closed drawers should not probe devices, fetch previews, or run screenshot loops. + - Open drawer, measure, close drawer, measure again. + +5. Memory checks: + - Use `ps`/Activity Monitor-style process sampling repeatedly. + - On macOS, `vmmap -summary` can help explain big RSS spikes. + - Distinguish DevTools memory from actual app memory by closing DevTools targets and rechecking. + +6. Cleanup after stress: + - Stop or dispose test PTYs/sessions you created. + - Do not kill user-created sessions unless they are clearly from the test. + - Stop the dev server before finishing unless the user asked to keep it running. + +--- + +## Phase 5: Fix the highest-impact causes + +Prefer fixes in this order: + +1. Remove runaway work: + - Stop polling when hidden, closed, or unfocused. + - Deduplicate identical in-flight calls. + - Debounce or throttle high-frequency refresh. + - Narrow full refreshes to runtime-only or count-only queries when possible. + +2. Reduce IPC and main-process load: + - Cache cold data with clear invalidation. + - Coalesce event streams, especially PTY data. + - Avoid repeated resize/write/status no-ops. + - Add service-level phase summaries so future regressions are visible. + +3. Reduce renderer and GPU load: + - Remove infinite decorative/status animations in persistent chrome. + - Make expensive renderers opt-in when the default can be cheaper. + - Do not mount hidden heavy panels if they can lazy-mount. + - Avoid re-render logs/effects that depend on unstable object identities. + - Virtualize large lists or cap expensive previews when needed. + +4. Reduce memory pressure: + - Lazy-load embedding/model/device work. + - Bound caches and transcripts. + - Avoid retaining full snapshots or logs in renderer state when summaries are enough. + - Reuse cached project/model/provider metadata with invalidation. + +5. Preserve UX: + - Keep controls responsive. + - Keep clear status feedback, but use static state where animation is not essential. + - Do not remove core functionality to make numbers look better. + - If an expensive feature is valuable, make it lazy, cached, or opt-in. + +--- + +## Phase 6: Verify + +Run the smallest meaningful checks first, then broaden. + +Desktop checks to choose from: + +```bash +npm --prefix apps/desktop run typecheck +npm --prefix apps/desktop run test -- +npm --prefix apps/desktop run test +npm --prefix apps/desktop run build +npm --prefix apps/desktop run lint +``` + +For IPC/preload/type changes, verify all synced surfaces: + - main handler + - shared IPC/type + - preload exposure + - renderer caller + - tests/mocks + +For renderer performance changes: + - Re-run the route sweep. + - Re-run Work/Lanes stress if touched. + - Confirm `document.getAnimations()` does not show persistent unnecessary animations. + - Confirm process CPU returns near idle after stress. + - Confirm logs do not show repeated full refreshes or identical calls. + +For terminal changes: + - Verify output is not lost on fast exit. + - Verify output streams while visible. + - Verify resize still works. + - Verify cleanup/dispose flushes pending data. + +For cache changes: + - Verify cold call and warm call behavior. + - Verify invalidation when the underlying file/config/state changes. + - Keep cache bounded. + +--- + +## Final report + +End with a concise report: + +1. Surfaces tested. +2. Hot paths found, with concrete measurements or log evidence. +3. Fixes made. +4. Before/after observations. +5. Validation commands and results. +6. Residual risks or future optimization targets. +7. Cleanup performed, including whether the dev server is stopped. + +If you found a suspected issue but did not fix it, say exactly why: not reproducible, too risky, needs product decision, or requires credentials/model spend. diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f7e8e3c07..56500dca4 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -99,7 +99,7 @@ import { createBatchConsolidationService } from "./services/memory/batchConsolid import { createEmbeddingService } from "./services/memory/embeddingService"; import { createEmbeddingWorkerService } from "./services/memory/embeddingWorkerService"; import { createHybridSearchService } from "./services/memory/hybridSearchService"; -import { createMemoryService } from "./services/memory/memoryService"; +import { createMemoryService, type Memory } from "./services/memory/memoryService"; import { createProjectMemoryFilesService } from "./services/memory/memoryFilesService"; import { createMemoryLifecycleService } from "./services/memory/memoryLifecycleService"; import { createMemoryBriefingService } from "./services/memory/memoryBriefingService"; @@ -232,6 +232,10 @@ function isBackgroundTaskEnabled(enableFlag?: string): boolean { ); } +function shouldEmbedMemory(memory: Pick): boolean { + return memory.status === "promoted" || memory.pinned === true; +} + const episodicSummaryEnabled = isBackgroundTaskEnabled( "ADE_ENABLE_EPISODIC_SUMMARY", ); @@ -1603,17 +1607,6 @@ app.whenReady().then(async () => { onEvent: (event) => emitProjectEvent(projectRoot, IPC.lanesRebaseSuggestionsEvent, event), }); - // Prime suggestions once on init so the UI can show them without waiting for a head change. - void rebaseSuggestionService - .listSuggestions() - .then((suggestions) => - emitProjectEvent(projectRoot, IPC.lanesRebaseSuggestionsEvent, { - type: "rebase-suggestions-updated", - computedAt: new Date().toISOString(), - suggestions, - }), - ) - .catch(() => {}); const githubService = createGithubService({ logger, @@ -2061,7 +2054,7 @@ app.whenReady().then(async () => { debouncedSyncMemoryDocs(); }, onMemoryUpserted: (event) => { - if (event.created || event.contentChanged) { + if ((event.created || event.contentChanged) && shouldEmbedMemory(event.memory)) { embeddingWorkerServiceRef?.queueMemory(event.memory.id); } }, @@ -2082,7 +2075,10 @@ app.whenReady().then(async () => { onStatus: (event) => emitProjectEvent(projectRoot, IPC.memoryConsolidationStatus, event), onMemoryInserted: (memoryId) => { - embeddingWorkerServiceRef?.queueMemory(memoryId); + const memory = memoryService.getMemory(memoryId); + if (memory && shouldEmbedMemory(memory)) { + embeddingWorkerServiceRef?.queueMemory(memoryId); + } }, }); batchConsolidationServiceRef = batchConsolidationService; diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index ba2cf3702..3ebf77926 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -682,6 +682,40 @@ function extractDiscoveredLocalModels(connections: AiRuntimeConnections): Discov return entries; } +const AI_STATUS_SLOW_PHASE_MS = 120; + +type AiStatusPhaseTiming = { + name: string; + durationMs: number; +}; + +function agentModelsFromAvailable( + available: Array>, + family: string, +): AgentModelDescriptor[] { + return available + .filter((descriptor) => descriptor.family === family) + .map((descriptor) => ({ + id: descriptor.id, + label: descriptor.displayName, + description: `${descriptor.family}${descriptor.isCliWrapped ? " (CLI)" : " (API/local)"}`, + })); +} + +type ModelDescriptorForStatus = ReturnType[number]; + +function buildStatusModelLists( + available: ModelDescriptorForStatus[], + availability: AiIntegrationStatus["availableProviders"], +): AiIntegrationStatus["models"] { + return { + claude: availability.claude ? agentModelsFromAvailable(available, "anthropic") : [], + codex: availability.codex ? agentModelsFromAvailable(available, "openai") : [], + cursor: availability.cursor ? agentModelsFromAvailable(available, "cursor") : [], + droid: availability.droid ? agentModelsFromAvailable(available, "factory") : [], + }; +} + export function createAiIntegrationService(args: { db: AdeDb; logger: Logger; @@ -720,11 +754,12 @@ export function createAiIntegrationService(args: { logger.warn("ai.modelsdev.init_failed", { error: err instanceof Error ? err.message : String(err) }); }); - const detectAuth = async (options?: { force?: boolean }): Promise => { + const detectAuth = async (options?: { force?: boolean; shallowCliAuth?: boolean }): Promise => { const snapshot = projectConfigService.get(); return await detectAllAuth(extractConfiguredApiKeys(snapshot), { - ...options, + force: options?.force, localProviders: extractConfiguredLocalProviders(snapshot), + skipCliAuthProbe: options?.shallowCliAuth === true, }); }; @@ -782,11 +817,15 @@ export function createAiIntegrationService(args: { }; }; - const getResolvedAvailableModels = async (auth: DetectedAuth[]) => { + const getResolvedAvailableModels = async ( + auth: DetectedAuth[], + options?: { discoverCliModels?: boolean }, + ) => { // Local model discovery is handled by OpenCode via probeOpenCodeProviderInventory // which populates dynamic OpenCode descriptors (including local providers). let available = getAvailableModels(auth); + const discoveryMode = options?.discoverCliModels === true ? "probe" : "cached-or-fallback"; const hasCursorCliAuth = auth.some( (entry) => @@ -797,7 +836,7 @@ export function createAiIntegrationService(args: { if (hasCursorCliAuth) { try { const { path: agentPath } = resolveCursorAgentExecutable(); - const cursorModels = await discoverCursorCliModelDescriptors(agentPath); + const cursorModels = await discoverCursorCliModelDescriptors(agentPath, { mode: discoveryMode }); available = [ ...available.filter((descriptor) => !(descriptor.family === "cursor" && descriptor.isCliWrapped)), ...cursorModels, @@ -817,7 +856,7 @@ export function createAiIntegrationService(args: { if (hasDroidCliAuth || hasDroidApiKey) { try { const { path: droidPath } = resolveDroidExecutable({ auth }); - const droidModels = await discoverDroidCliModelDescriptors(droidPath); + const droidModels = await discoverDroidCliModelDescriptors(droidPath, { mode: discoveryMode }); available = [ ...available.filter((descriptor) => !(descriptor.family === "factory" && descriptor.isCliWrapped)), ...droidModels, @@ -1156,7 +1195,7 @@ export function createAiIntegrationService(args: { } const auth = await detectAuth(); - const available = await getResolvedAvailableModels(auth); + const available = await getResolvedAvailableModels(auth, { discoverCliModels: true }); let family: string; if (provider === "codex") { family = "openai"; @@ -1253,114 +1292,182 @@ export function createAiIntegrationService(args: { } const request = (async (): Promise => { - const auth = await detectAuth(options); - const available = await getResolvedAvailableModels(auth); - // detectAuth -> detectAllAuth already called detectCliAuthStatuses() and - // populated the cache, so this reads instantly from cache: - const cliStatuses = getCachedCliAuthStatuses(); - const claudeCli = cliStatuses.find((entry) => entry.cli === "claude"); - if (claudeCli?.installed && options?.force) { - await probeClaudeRuntimeHealth({ - projectRoot, - logger, - force: true, - }); - runtimeHealthVersion = getProviderRuntimeHealthVersion(); - } - const providerConnections = await buildProviderConnections(cliStatuses); - const configuredLocalProviders = extractConfiguredLocalProviders(projectConfigService.get()); - const runtimeConnections = await buildRuntimeConnections({ - configuredLocalProviders, - auth, - providerConnections, - }); - const availability = { - claude: providerConnections.claude.runtimeAvailable, - codex: providerConnections.codex.runtimeAvailable, - cursor: providerConnections.cursor.runtimeAvailable, - droid: providerConnections.droid.runtimeAvailable, + const requestId = randomUUID(); + const totalStartedAt = Date.now(); + const phases: AiStatusPhaseTiming[] = []; + const shouldProbeCliModels = options?.force === true || options?.refreshOpenCodeInventory === true; + const phaseContext = { + requestId, + force: options?.force === true, + refreshOpenCodeInventory: options?.refreshOpenCodeInventory === true, + probeCliModels: shouldProbeCliModels, }; - const runtimeFilteredAvailable = available.filter((descriptor) => { - if (!descriptor.isCliWrapped) return true; - if (descriptor.family === "anthropic") return providerConnections.claude.runtimeAvailable; - if (descriptor.family === "openai") return providerConnections.codex.runtimeAvailable; - if (descriptor.family === "cursor") return providerConnections.cursor.runtimeAvailable; - if (descriptor.family === "factory") return providerConnections.droid.runtimeAvailable; - return true; - }); + const recordPhase = (name: string, startedAt: number) => { + const durationMs = Date.now() - startedAt; + phases.push({ name, durationMs }); + if (durationMs >= AI_STATUS_SLOW_PHASE_MS) { + logger.info("ai.status.phase", { + ...phaseContext, + phase: name, + durationMs, + }); + } + }; + const timePhase = async (name: string, fn: () => Promise): Promise => { + const startedAt = Date.now(); + try { + return await fn(); + } finally { + recordPhase(name, startedAt); + } + }; + const timeSyncPhase = (name: string, fn: () => T): T => { + const startedAt = Date.now(); + try { + return fn(); + } finally { + recordPhase(name, startedAt); + } + }; + const phaseSummary = () => phases + .filter((phase) => phase.durationMs >= 10) + .map((phase) => ({ phase: phase.name, durationMs: phase.durationMs })); + + try { + const auth = await timePhase("detect_auth", () => detectAuth({ + force: options?.force, + shallowCliAuth: options?.force !== true, + })); + const available = await timePhase("resolve_available_models", () => + getResolvedAvailableModels(auth, { discoverCliModels: shouldProbeCliModels }) + ); + // detectAuth -> detectAllAuth already called detectCliAuthStatuses() and + // populated the cache, so this reads instantly from cache: + const cliStatuses = timeSyncPhase("read_cli_auth_cache", () => getCachedCliAuthStatuses()); + const claudeCli = cliStatuses.find((entry) => entry.cli === "claude"); + if (claudeCli?.installed && options?.force) { + await timePhase("probe_claude_runtime", () => probeClaudeRuntimeHealth({ + projectRoot, + logger, + force: true, + })); + runtimeHealthVersion = getProviderRuntimeHealthVersion(); + } + const providerConnections = await timePhase("build_provider_connections", () => buildProviderConnections(cliStatuses)); + const configuredLocalProviders = timeSyncPhase( + "read_local_provider_config", + () => extractConfiguredLocalProviders(projectConfigService.get()), + ); + const runtimeConnections = await timePhase("build_runtime_connections", () => buildRuntimeConnections({ + configuredLocalProviders, + auth, + providerConnections, + })); + const availability = { + claude: providerConnections.claude.runtimeAvailable, + codex: providerConnections.codex.runtimeAvailable, + cursor: providerConnections.cursor.runtimeAvailable, + droid: providerConnections.droid.runtimeAvailable, + }; + const runtimeFilteredAvailable = timeSyncPhase("filter_available_models", () => available.filter((descriptor) => { + if (!descriptor.isCliWrapped) return true; + if (descriptor.family === "anthropic") return providerConnections.claude.runtimeAvailable; + if (descriptor.family === "openai") return providerConnections.codex.runtimeAvailable; + if (descriptor.family === "cursor") return providerConnections.cursor.runtimeAvailable; + if (descriptor.family === "factory") return providerConnections.droid.runtimeAvailable; + return true; + })); + + const opencodeBinaryInfo = timeSyncPhase("resolve_opencode_binary", () => resolveOpenCodeBinary()); + const opencodeBinaryInstalled = Boolean(opencodeBinaryInfo.path); + const opencodeBinarySource = opencodeBinaryInfo.source; + const effectiveConfig = timeSyncPhase("read_effective_config", () => projectConfigService.get().effective); + // Extract discovered local models from runtime connections so we can + // inject them into the OpenCode provider config. This bridges ADE's + // local model discovery with OpenCode's static provider model list. + const discoveredLocalModels = timeSyncPhase("extract_local_models", () => extractDiscoveredLocalModels(runtimeConnections)); + const opencodeInventory = await timePhase("opencode_inventory", async () => { + if (!opencodeBinaryInstalled) { + clearOpenCodeInventoryCache(); + replaceDynamicOpenCodeModelDescriptors([]); + return { + error: null as string | null, + modelIds: [] as string[], + providers: [] as NonNullable, + }; + } + if (options?.refreshOpenCodeInventory === true) { + return await probeOpenCodeProviderInventory({ + projectRoot, + projectConfig: effectiveConfig, + logger, + force: true, + discoveredLocalModels, + }); + } + const peeked = peekOpenCodeInventoryCache({ + projectRoot, + projectConfig: effectiveConfig, + }); + return peeked ?? { + error: null as string | null, + modelIds: [] as string[], + providers: [] as NonNullable, + }; + }); - const opencodeBinaryInfo = resolveOpenCodeBinary(); - const opencodeBinaryInstalled = Boolean(opencodeBinaryInfo.path); - const opencodeBinarySource = opencodeBinaryInfo.source; - let opencodeInventoryError: string | null = null; - let opencodeModelIds: string[] = []; - let opencodeProviders: AiIntegrationStatus["opencodeProviders"] = []; - const effectiveConfig = projectConfigService.get().effective; - // Extract discovered local models from runtime connections so we can - // inject them into the OpenCode provider config. This bridges ADE's - // local model discovery with OpenCode's static provider model list. - const discoveredLocalModels = extractDiscoveredLocalModels(runtimeConnections); - if (!opencodeBinaryInstalled) { - clearOpenCodeInventoryCache(); - replaceDynamicOpenCodeModelDescriptors([]); - } else if (options?.refreshOpenCodeInventory === true) { - const probed = await probeOpenCodeProviderInventory({ - projectRoot, - projectConfig: effectiveConfig, - logger, - force: true, - discoveredLocalModels, + // When OpenCode inventory has models for a local provider, remove the + // duplicate ADE-discovered entries to avoid showing the same model twice. + const mergedAvailableIds = timeSyncPhase("merge_available_model_ids", () => { + const opencodeLocalModelIds = new Set(); + for (const ocId of opencodeInventory.modelIds) { + const decoded = decodeOpenCodeRegistryId(ocId); + if (decoded && isLocalProviderFamily(decoded.openCodeProviderId)) { + opencodeLocalModelIds.add(`${decoded.openCodeProviderId}/${decoded.openCodeModelId}`); + } + } + const baseAvailableIds = runtimeFilteredAvailable + .map((descriptor) => descriptor.id) + .filter((id) => !opencodeLocalModelIds.has(id)); + return [...new Set([...baseAvailableIds, ...opencodeInventory.modelIds])]; }); - opencodeInventoryError = probed.error; - opencodeModelIds = probed.modelIds; - opencodeProviders = probed.providers; - } else { - const peeked = peekOpenCodeInventoryCache({ - projectRoot, - projectConfig: effectiveConfig, + const models = timeSyncPhase("build_model_lists", () => buildStatusModelLists(runtimeFilteredAvailable, availability)); + + const result: AiIntegrationStatus = { + mode: timeSyncPhase("derive_mode", () => deriveMode({ snapshot: projectConfigService.get(), auth, providerConnections })), + availableProviders: availability, + models, + detectedAuth: timeSyncPhase("redact_auth", () => redactDetectedAuth(auth, cliStatuses)), + providerConnections, + runtimeConnections, + availableModelIds: mergedAvailableIds, + opencodeBinaryInstalled, + opencodeBinarySource, + opencodeInventoryError: opencodeInventory.error, + opencodeProviders: opencodeInventory.providers, + apiKeyStore: timeSyncPhase("api_key_store_status", () => getApiKeyStoreStatus()), + }; + statusCache = { result, cachedAt: Date.now(), runtimeHealthVersion }; + logger.info("ai.status.summary", { + ...phaseContext, + durationMs: Date.now() - totalStartedAt, + phaseCount: phases.length, + phases: phaseSummary(), + authCount: auth.length, + availableModelCount: mergedAvailableIds.length, + providerAvailability: availability, + opencodeModelCount: opencodeInventory.modelIds.length, }); - if (peeked) { - opencodeInventoryError = peeked.error; - opencodeModelIds = peeked.modelIds; - opencodeProviders = peeked.providers; - } - } - - // When OpenCode inventory has models for a local provider, remove the - // duplicate ADE-discovered entries to avoid showing the same model twice. - const opencodeLocalModelIds = new Set(); - for (const ocId of opencodeModelIds) { - const decoded = decodeOpenCodeRegistryId(ocId); - if (decoded && isLocalProviderFamily(decoded.openCodeProviderId)) { - opencodeLocalModelIds.add(`${decoded.openCodeProviderId}/${decoded.openCodeModelId}`); - } + return result; + } catch (error) { + logger.warn("ai.status.failed", { + ...phaseContext, + durationMs: Date.now() - totalStartedAt, + phases: phaseSummary(), + error: error instanceof Error ? error.message : String(error), + }); + throw error; } - const baseAvailableIds = runtimeFilteredAvailable - .map((descriptor) => descriptor.id) - .filter((id) => !opencodeLocalModelIds.has(id)); - const mergedAvailableIds = [...new Set([...baseAvailableIds, ...opencodeModelIds])]; - - const result: AiIntegrationStatus = { - mode: deriveMode({ snapshot: projectConfigService.get(), auth, providerConnections }), - availableProviders: availability, - models: { - claude: availability.claude ? await listModels("claude") : [], - codex: availability.codex ? await listModels("codex") : [], - cursor: availability.cursor ? await listModels("cursor") : [], - droid: availability.droid ? await listModels("droid") : [], - }, - detectedAuth: redactDetectedAuth(auth, cliStatuses), - providerConnections, - runtimeConnections, - availableModelIds: mergedAvailableIds, - opencodeBinaryInstalled, - opencodeBinarySource, - opencodeInventoryError, - opencodeProviders, - apiKeyStore: getApiKeyStoreStatus(), - }; - statusCache = { result, cachedAt: Date.now(), runtimeHealthVersion }; - return result; })(); statusRequestsInFlight.set(requestKey, request); diff --git a/apps/desktop/src/main/services/ai/authDetector.test.ts b/apps/desktop/src/main/services/ai/authDetector.test.ts index 91a9d5269..65dc4e7c3 100644 --- a/apps/desktop/src/main/services/ai/authDetector.test.ts +++ b/apps/desktop/src/main/services/ai/authDetector.test.ts @@ -122,6 +122,38 @@ describe("authDetector", () => { }); }); + it("can skip expensive CLI auth probes for passive status checks", async () => { + spawnMock.mockImplementation((command: string, args: string[] = []) => { + if (args[0] === "--version") { + if (command === "claude") return fakeChild({ status: 0, stdout: "1.0.0\n" }); + return fakeError(); + } + if (command === "which") { + if (args[0] === "claude") return fakeChild({ status: 0, stdout: "/usr/local/bin/claude\n" }); + return fakeChild({ status: 1 }); + } + if ((command === "claude" || command.endsWith("/claude")) && args[0] === "auth") { + throw new Error("auth probe should not run"); + } + return fakeChild({ status: 1 }); + }); + + const statuses = await detectCliAuthStatuses({ skipAuthProbe: true }); + const claude = statuses.find((entry) => entry.cli === "claude"); + + expect(claude).toEqual({ + cli: "claude", + installed: true, + path: "/usr/local/bin/claude", + authenticated: true, + verified: false, + }); + expect(spawnMock.mock.calls.some(([command, args]) => { + const argv = Array.isArray(args) ? args : []; + return String(command).includes("claude") && argv[0] === "auth"; + })).toBe(false); + }); + it("merges config, store, env, and local endpoint auth sources", async () => { getAllApiKeysMock.mockReturnValue({ anthropic: "store-anthropic", @@ -254,7 +286,7 @@ describe("authDetector", () => { return fakeChild({ status: 1 }); }); - const statuses = await detectCliAuthStatuses(); + const statuses = await detectCliAuthStatuses({ force: true }); const droid = statuses.find((entry) => entry.cli === "droid"); expect(droid).toEqual({ @@ -266,6 +298,41 @@ describe("authDetector", () => { }); }); + it("skips deep Droid auth probes during default detection without stored credentials", async () => { + tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-droid-auth-shallow-")); + process.env.HOME = tempHomeDir; + const droidBinDir = path.join(tempHomeDir, ".local", "bin"); + fs.mkdirSync(droidBinDir, { recursive: true }); + const fakeDroidPath = path.join(droidBinDir, "droid"); + fs.writeFileSync(fakeDroidPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + process.env.PATH = ""; + + spawnMock.mockImplementation((command: string, _args: string[] = []) => { + if (command === "which") { + return fakeChild({ status: 1 }); + } + return fakeError(); + }); + + const statuses = await detectCliAuthStatuses(); + const droid = statuses.find((entry) => entry.cli === "droid"); + + expect(droid).toEqual({ + cli: "droid", + installed: true, + path: fakeDroidPath, + authenticated: false, + verified: false, + }); + const droidDeepProbeCalls = spawnMock.mock.calls.filter(([command, args]) => { + const commandText = String(command); + const argv = Array.isArray(args) ? args as string[] : []; + return (commandText === "droid" || commandText.endsWith("/droid")) + && (argv[0] === "exec" || argv[0] === "account" || argv[0] === "whoami"); + }); + expect(droidDeepProbeCalls).toHaveLength(0); + }); + it("does not report openai-compatible local providers when no models are loaded", async () => { vi.stubGlobal( "fetch", diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index 98041bf57..04313ec4c 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -135,6 +135,9 @@ function findExplicitCommandPath(command: string): string | null { } async function commandExists(command: string): Promise { + const explicitPath = findExplicitCommandPath(command); + if (explicitPath) return true; + // Strategy 1: Direct spawn — bypasses shell init (.zshrc errors, slow profiles). // If the binary exists, --version will produce *some* exit code. // A spawn error (ENOENT) means the binary isn't on PATH → status is null. @@ -145,9 +148,6 @@ async function commandExists(command: string): Promise { // fall through to shell-based check } - const explicitPath = findExplicitCommandPath(command); - if (explicitPath) return true; - // Strategy 2: Shell-based lookup (fallback for edge cases) try { if (process.platform === "win32") { @@ -372,11 +372,37 @@ async function inspectCursorCliAuthentication(command: string): Promise<{ return { authenticated: false, verified: false, paidPlan: false }; } -async function inspectDroidCliPresence(command: string): Promise<{ +async function hasDroidConfiguredCredentials(): Promise { + if (process.env.FACTORY_API_KEY?.trim()) { + return true; + } + + const settingsPath = path.join(homedir(), ".factory", "settings.json"); + try { + const raw = await readFile(settingsPath, "utf8"); + const parsed = JSON.parse(raw) as Record; + const tokenLike = + typeof parsed.accessToken === "string" && parsed.accessToken.trim().length > 0 + ? parsed.accessToken + : typeof parsed.token === "string" && parsed.token.trim().length > 0 + ? parsed.token + : null; + return Boolean(tokenLike); + } catch { + return false; + } +} + +async function inspectDroidCliPresence(command: string, options?: { deep?: boolean }): Promise<{ installed: boolean; authenticated: boolean; verified: boolean; }> { + if (!options?.deep) { + const authenticated = await hasDroidConfiguredCredentials(); + return { installed: true, authenticated, verified: authenticated }; + } + const probes = CLI_AUTH_PROBES.droid ?? []; let sawVersionOk = false; for (const args of probes) { @@ -394,27 +420,10 @@ async function inspectDroidCliPresence(command: string): Promise<{ return { installed: false, authenticated: false, verified: false }; } - if (process.env.FACTORY_API_KEY?.trim()) { + if (await hasDroidConfiguredCredentials()) { return { installed: true, authenticated: true, verified: true }; } - const settingsPath = path.join(homedir(), ".factory", "settings.json"); - try { - const raw = await readFile(settingsPath, "utf8"); - const parsed = JSON.parse(raw) as Record; - const tokenLike = - typeof parsed.accessToken === "string" && parsed.accessToken.trim().length > 0 - ? parsed.accessToken - : typeof parsed.token === "string" && parsed.token.trim().length > 0 - ? parsed.token - : null; - if (tokenLike) { - return { installed: true, authenticated: true, verified: true }; - } - } catch { - // missing or unreadable settings — not authenticated via file - } - try { const result = await spawnAsync(command, ["exec", "--list-tools"], { timeout: 12_000 }); const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); @@ -581,49 +590,49 @@ async function detectLocalProviders( } const normalized = normalizeLocalProviderConfig(config); - const entries: Array<{ + const detectProvider = async (provider: LocalProviderFamily): Promise = []; - - for (const provider of ["ollama", "lmstudio"] as const) { + }>> => { const providerConfig = normalized[provider]; - if (!providerConfig.enabled) continue; + if (!providerConfig.enabled) return []; const configuredEndpoint = providerConfig.endpoint; if (configuredEndpoint) { const inspection = await inspectLocalProvider(provider, configuredEndpoint, LOCAL_ENDPOINT_CHECK_TIMEOUT_MS); if (inspection.health === "ready") { - entries.push({ + return [{ provider, endpoint: configuredEndpoint, endpointSource: "config", preferredModelId: providerConfig.preferredModelId ?? null, - }); - continue; + }]; } if (!providerConfig.autoDetect) { - continue; + return []; } } - if (!providerConfig.autoDetect) continue; + if (!providerConfig.autoDetect) return []; const autoEndpoint = getLocalProviderDefaultEndpoint(provider); if (configuredEndpoint && autoEndpoint.replace(/\/+$/, "") === configuredEndpoint.replace(/\/+$/, "")) { - continue; + return []; } const autoInspection = await inspectLocalProvider(provider, autoEndpoint, LOCAL_ENDPOINT_CHECK_TIMEOUT_MS); if (autoInspection.health === "ready") { - entries.push({ + return [{ provider, endpoint: autoEndpoint, endpointSource: "auto", preferredModelId: providerConfig.preferredModelId ?? null, - }); + }]; } - } + return []; + }; + + const entries = (await Promise.all((["ollama", "lmstudio"] as const).map(detectProvider))).flat(); cachedLocalProviders = { key: cacheKey, checkedAtMs: now, entries }; return entries; @@ -984,16 +993,22 @@ export async function verifyProviderApiKey( // CLI auth cache — avoid re-probing every time a dialog opens // --------------------------------------------------------------------------- const CLI_AUTH_CACHE_TTL_MS = 60_000; // 1 minute -let cachedCliAuth: { checkedAtMs: number; statuses: CliAuthStatus[] } | null = null; +let cachedCliAuth: { checkedAtMs: number; statuses: CliAuthStatus[]; skipAuthProbe: boolean } | null = null; /** Synchronous read from cache — returns empty array if not yet populated. */ export function getCachedCliAuthStatuses(): CliAuthStatus[] { return cachedCliAuth?.statuses ?? []; } -export async function detectCliAuthStatuses(options?: { force?: boolean }): Promise { +export async function detectCliAuthStatuses(options?: { force?: boolean; skipAuthProbe?: boolean }): Promise { const now = Date.now(); - if (!options?.force && cachedCliAuth && now - cachedCliAuth.checkedAtMs < CLI_AUTH_CACHE_TTL_MS) { + const skipAuthProbe = options?.skipAuthProbe === true && options.force !== true; + if ( + !options?.force + && cachedCliAuth + && now - cachedCliAuth.checkedAtMs < CLI_AUTH_CACHE_TTL_MS + && (!cachedCliAuth.skipAuthProbe || skipAuthProbe) + ) { return cachedCliAuth.statuses; } @@ -1021,6 +1036,16 @@ export async function detectCliAuthStatuses(options?: { force?: boolean }): Prom verified: false, }; } + if (skipAuthProbe && cli !== "droid") { + return { + cli, + installed, + path, + authenticated: true, + verified: false, + ...(cli === "cursor" ? { paidPlan: true } : {}), + }; + } if (cli === "cursor") { const auth = await inspectCursorCliAuthentication(cmd); return { @@ -1051,7 +1076,7 @@ export async function detectCliAuthStatuses(options?: { force?: boolean }): Prom } droidPath = resolved.path; } - const auth = await inspectDroidCliPresence(droidPath); + const auth = await inspectDroidCliPresence(droidPath, { deep: options?.force === true }); return { cli, installed: auth.installed, @@ -1071,18 +1096,21 @@ export async function detectCliAuthStatuses(options?: { force?: boolean }): Prom }), ); - cachedCliAuth = { checkedAtMs: now, statuses }; + cachedCliAuth = { checkedAtMs: now, statuses, skipAuthProbe }; return statuses; } export async function detectAllAuth( configApiKeys?: Record, - options?: { force?: boolean; localProviders?: AiLocalProviderConfigs }, + options?: { force?: boolean; localProviders?: AiLocalProviderConfigs; skipCliAuthProbe?: boolean }, ): Promise { const results: DetectedAuth[] = []; // 1. CLI subscriptions (connected and authenticated) - const cliStatuses = await detectCliAuthStatuses(options); + const cliStatuses = await detectCliAuthStatuses({ + force: options?.force, + skipAuthProbe: options?.skipCliAuthProbe, + }); for (const cli of cliStatuses) { if (cli.cli !== "claude" && cli.cli !== "codex" && cli.cli !== "cursor" && cli.cli !== "droid") continue; if (!cli.installed) continue; diff --git a/apps/desktop/src/main/services/ai/tools/memoryTools.test.ts b/apps/desktop/src/main/services/ai/tools/memoryTools.test.ts index b111e657a..74f86bc48 100644 --- a/apps/desktop/src/main/services/ai/tools/memoryTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/memoryTools.test.ts @@ -9,8 +9,8 @@ describe("createMemoryTools", () => { accepted: true, memory: { id: "memory-1", - tier: 3, - status: "candidate", + tier: 2, + status: "promoted", }, deduped: false, })), @@ -44,9 +44,9 @@ describe("createMemoryTools", () => { expect.objectContaining({ scope: "mission", scopeOwnerId: "run-1", - status: "candidate", - tier: 3, - confidence: 0.6, + status: "promoted", + tier: 2, + confidence: 1, }) ); }); @@ -58,8 +58,8 @@ describe("createMemoryTools", () => { accepted: true, memory: { id: "memory-2", - tier: 3, - status: "candidate", + tier: 2, + status: "promoted", }, deduped: false, })), @@ -93,9 +93,9 @@ describe("createMemoryTools", () => { expect.objectContaining({ scope: "agent", scopeOwnerId: "agent-session-1", - status: "candidate", - tier: 3, - confidence: 0.6, + status: "promoted", + tier: 2, + confidence: 1, }) ); }); diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts index 7bfb4347d..e7c879f68 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts @@ -6,6 +6,7 @@ import { import { spawnAsync } from "../shared/utils"; export type CursorCliModelRow = { id: string; displayName?: string }; +type CursorCliModelDiscoveryMode = "probe" | "cached-or-fallback"; let cached: { at: number; models: CursorCliModelRow[] } | null = null; const TTL_MS = 120_000; @@ -50,6 +51,14 @@ export function clearCursorCliModelsCache(): void { cached = null; } +function getCachedCursorModels(): CursorCliModelRow[] | null { + const now = Date.now(); + if (cached && now - cached.at < TTL_MS && cached.models.length) { + return cached.models; + } + return null; +} + /** * Best-effort: run `agent models` (and JSON variants) and parse stdout. */ @@ -117,8 +126,13 @@ export async function listCursorModelsFromCli(agentPath: string): Promise { - const rows = await listCursorModelsFromCli(agentPath); +export async function discoverCursorCliModelDescriptors( + agentPath: string, + options?: { mode?: CursorCliModelDiscoveryMode }, +): Promise { + const rows = options?.mode === "cached-or-fallback" + ? getCachedCursorModels() ?? [] + : await listCursorModelsFromCli(agentPath); const useRows: CursorCliModelRow[] = rows.length ? rows : FALLBACK_SDK_IDS.map((id) => ({ id })); const seen = new Set(); const descriptors: ModelDescriptor[] = []; diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts index 471d62fa0..8ef8b04f2 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -42,6 +42,7 @@ export type DroidExecHelpModelRow = { /** True when sourced from ~/.factory/config.json (vibeproxy / custom proxy). */ customProxy?: boolean; }; +type DroidCliModelDiscoveryMode = "probe" | "cached-or-fallback"; let cached: { at: number; models: DroidExecHelpModelRow[] } | null = null; let inflight: Promise | null = null; @@ -189,6 +190,14 @@ export function clearDroidCliModelsCache(): void { inflight = null; } +function getCachedDroidModels(): DroidExecHelpModelRow[] | null { + const now = Date.now(); + if (cached && now - cached.at < TTL_MS) { + return cached.models; + } + return null; +} + /** * Read custom models from `~/.factory/config.json`. * @@ -221,8 +230,13 @@ async function readFactoryConfigCustomModels(): Promise } } -export async function discoverDroidCliModelDescriptors(droidPath: string): Promise { - const fromCli = await listDroidModelsFromCli(droidPath); +export async function discoverDroidCliModelDescriptors( + droidPath: string, + options?: { mode?: DroidCliModelDiscoveryMode }, +): Promise { + const fromCli = options?.mode === "cached-or-fallback" + ? getCachedDroidModels() ?? [] + : await listDroidModelsFromCli(droidPath); const baseRows: DroidExecHelpModelRow[] = fromCli.length ? fromCli : DROID_DEFAULT_MODEL_IDS.map((id) => ({ id, displayName: id })); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 3de6bb5f7..e9dae0fed 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -382,6 +382,7 @@ import type { SyncDesktopConnectionDraft, SyncDeviceRecord, SyncDeviceRuntimeState, + SyncGetStatusArgs, SyncPeerDeviceType, SyncRoleSnapshot, SyncTransferReadiness, @@ -853,18 +854,60 @@ async function enrichSessionsForLaneList( } async function buildLaneListSnapshots( - args: Pick & { + args: Pick & { syncService?: ReturnType | null; }, lanes: LaneSummary[], + options: { includeConflictStatus?: boolean; includeRebaseSuggestions?: boolean; includeAutoRebaseStatus?: boolean } = {}, ): Promise { + const startedAt = Date.now(); + const phases: Array<{ phase: string; durationMs: number }> = []; + const timePhase = async (phase: string, work: Promise): Promise => { + const phaseStartedAt = Date.now(); + try { + return await work; + } finally { + const durationMs = Date.now() - phaseStartedAt; + phases.push({ phase, durationMs }); + if (durationMs >= 120) { + args.logger.info("lanes.listSnapshots.phase", { + phase, + durationMs, + laneCount: lanes.length, + includeConflictStatus: options.includeConflictStatus !== false, + includeRebaseSuggestions: options.includeRebaseSuggestions !== false, + includeAutoRebaseStatus: options.includeAutoRebaseStatus !== false, + }); + } + } + }; + const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ - enrichSessionsForLaneList(args), - Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []).catch(() => []), - Promise.resolve(args.autoRebaseService?.listStatuses() ?? []).catch(() => []), - Promise.resolve(args.laneService.listStateSnapshots()).catch(() => []), - args.conflictService?.getBatchAssessment({ lanes }).catch(() => null) ?? Promise.resolve(null), + timePhase("sessions", enrichSessionsForLaneList(args)), + options.includeRebaseSuggestions === false + ? Promise.resolve([]) + : timePhase("rebase_suggestions", Promise.resolve(args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []).catch(() => [])), + options.includeAutoRebaseStatus === false + ? Promise.resolve([]) + : timePhase("auto_rebase_statuses", Promise.resolve(args.autoRebaseService?.listStatuses({ lanes }) ?? []).catch(() => [])), + timePhase("state_snapshots", Promise.resolve(args.laneService.listStateSnapshots()).catch(() => [])), + options.includeConflictStatus === false + ? Promise.resolve(null) + : timePhase("conflict_assessment", args.conflictService?.getBatchAssessment({ lanes }).catch(() => null) ?? Promise.resolve(null)), ]); + const durationMs = Date.now() - startedAt; + if (durationMs >= 120) { + args.logger.info("lanes.listSnapshots.summary", { + durationMs, + laneCount: lanes.length, + includeConflictStatus: options.includeConflictStatus !== false, + includeRebaseSuggestions: options.includeRebaseSuggestions !== false, + includeAutoRebaseStatus: options.includeAutoRebaseStatus !== false, + phases: phases + .filter((phase) => phase.durationMs >= 10) + .sort((left, right) => right.durationMs - left.durationMs), + }); + } const rebaseByLaneId = new Map(rebaseSuggestions.map((entry) => [entry.laneId, entry] as const)); const autoRebaseByLaneId = new Map(autoRebaseStatuses.map((entry) => [entry.laneId, entry] as const)); @@ -1714,6 +1757,23 @@ export function registerIpc({ return typeof value; }; + const summarizeIpcArg = (value: unknown): unknown => { + if (!value || typeof value !== "object" || Array.isArray(value) || value instanceof Date) { + return summarizeIpcValue(value); + } + const record = value as Record; + const entries = Object.entries(record).slice(0, 8); + return Object.fromEntries(entries.map(([key, entryValue]) => [key, summarizeIpcValue(entryValue, 1)])); + }; + + const summarizeIpcArgs = (args: unknown[]): unknown => ({ + kind: "array", + length: args.length, + ...(args.length > 0 ? { arg0: summarizeIpcArg(args[0]) } : {}), + ...(args.length > 1 ? { arg1: summarizeIpcArg(args[1]) } : {}), + ...(args.length > 2 ? { arg2: summarizeIpcArg(args[2]) } : {}), + }); + const getTraceLogger = (): Pick => { try { return getCtx().logger; @@ -1772,6 +1832,91 @@ export function registerIpc({ } }; + type IpcInvokeAggregate = { + channel: string; + winId: number | null; + count: number; + failed: number; + totalDurationMs: number; + maxDurationMs: number; + slowCount: number; + }; + + const IPC_SUMMARY_INTERVAL_MS = 10_000; + const ipcInvokeAggregates = new Map(); + let ipcInvokeSummaryTimer: NodeJS.Timeout | null = null; + + const flushIpcInvokeSummary = () => { + ipcInvokeSummaryTimer = null; + if (ipcInvokeAggregates.size === 0) return; + const rows = [...ipcInvokeAggregates.values()]; + ipcInvokeAggregates.clear(); + const totalCalls = rows.reduce((sum, row) => sum + row.count, 0); + const totalDurationMs = rows.reduce((sum, row) => sum + row.totalDurationMs, 0); + const topByCount = [...rows] + .sort((left, right) => right.count - left.count || right.totalDurationMs - left.totalDurationMs) + .slice(0, 12) + .map((row) => ({ + channel: row.channel, + winId: row.winId, + count: row.count, + avgMs: Math.round(row.totalDurationMs / Math.max(1, row.count)), + maxMs: row.maxDurationMs, + slowCount: row.slowCount, + failed: row.failed, + })); + const topByCost = [...rows] + .sort((left, right) => right.totalDurationMs - left.totalDurationMs || right.count - left.count) + .slice(0, 12) + .map((row) => ({ + channel: row.channel, + winId: row.winId, + count: row.count, + totalMs: row.totalDurationMs, + avgMs: Math.round(row.totalDurationMs / Math.max(1, row.count)), + maxMs: row.maxDurationMs, + slowCount: row.slowCount, + failed: row.failed, + })); + + getTraceLogger().info("ipc.invoke.summary", { + intervalMs: IPC_SUMMARY_INTERVAL_MS, + totalCalls, + totalDurationMs, + topByCount, + topByCost, + }); + }; + + const recordIpcInvokeAggregate = (input: { + channel: string; + winId: number | null; + durationMs: number; + failed: boolean; + }) => { + const key = `${input.winId ?? "none"}:${input.channel}`; + const existing = ipcInvokeAggregates.get(key) ?? { + channel: input.channel, + winId: input.winId, + count: 0, + failed: 0, + totalDurationMs: 0, + maxDurationMs: 0, + slowCount: 0, + }; + existing.count += 1; + existing.failed += input.failed ? 1 : 0; + existing.totalDurationMs += input.durationMs; + existing.maxDurationMs = Math.max(existing.maxDurationMs, input.durationMs); + if (input.durationMs >= 120) existing.slowCount += 1; + ipcInvokeAggregates.set(key, existing); + + if (!ipcInvokeSummaryTimer) { + ipcInvokeSummaryTimer = setTimeout(flushIpcInvokeSummary, IPC_SUMMARY_INTERVAL_MS); + ipcInvokeSummaryTimer.unref?.(); + } + }; + const tracedIpcMain = ipcMain as TracedIpcMain; if (traceIpcInvokes && !tracedIpcMain.__adeTraceWrapped) { const originalHandle = tracedIpcMain.handle.bind(ipcMain); @@ -1793,7 +1938,7 @@ export function registerIpc({ return null; } })(), - args: summarizeIpcValue(redactIpcArgsForChannel(channel, args)), + args: summarizeIpcArgs(redactIpcArgsForChannel(channel, args)), }); const IPC_TIMEOUT_MS = ipcInvokeTimeoutMs(channel); let timeoutHandle: NodeJS.Timeout | null = null; @@ -1807,20 +1952,24 @@ export function registerIpc({ ); }), ]); + const durationMs = Date.now() - startedAt; + recordIpcInvokeAggregate({ channel, winId, durationMs, failed: false }); logger.info("ipc.invoke.done", { callId, channel, winId, - durationMs: Date.now() - startedAt, + durationMs, result: summarizeIpcValue(result), }); return result; } catch (error) { + const durationMs = Date.now() - startedAt; + recordIpcInvokeAggregate({ channel, winId, durationMs, failed: true }); logger.warn("ipc.invoke.failed", { callId, channel, winId, - durationMs: Date.now() - startedAt, + durationMs, err: getErrorMessage(error), }); throw error; @@ -3069,8 +3218,11 @@ export function registerIpc({ }); }); - ipcMain.handle(IPC.syncGetStatus, async (): Promise => { - return await (await requireSyncService()).getStatus(); + ipcMain.handle(IPC.syncGetStatus, async (_event, arg?: SyncGetStatusArgs): Promise => { + return await (await requireSyncService()).getStatus({ + includeTransferReadiness: arg?.includeTransferReadiness, + forceTransferReadiness: arg?.forceTransferReadiness, + }); }); ipcMain.handle(IPC.syncRefreshDiscovery, async (): Promise => { @@ -4312,11 +4464,18 @@ export function registerIpc({ includeArchived: Boolean(arg?.includeArchived), includeStatus: arg?.includeStatus !== false, }); - return await buildLaneListSnapshots(ctx, lanes); + return await buildLaneListSnapshots(ctx, lanes, { + includeConflictStatus: arg?.includeConflictStatus !== false, + includeRebaseSuggestions: arg?.includeRebaseSuggestions !== false, + includeAutoRebaseStatus: arg?.includeAutoRebaseStatus !== false, + }); }, { includeArchived: Boolean(arg?.includeArchived), includeStatus: arg?.includeStatus !== false, + includeConflictStatus: arg?.includeConflictStatus !== false, + includeRebaseSuggestions: arg?.includeRebaseSuggestions !== false, + includeAutoRebaseStatus: arg?.includeAutoRebaseStatus !== false, } ); }); diff --git a/apps/desktop/src/main/services/lanes/autoRebaseService.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.ts index a3379c241..d7fd47653 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.ts @@ -19,6 +19,7 @@ type StoredDismissal = { }; type ListStatusesOptions = { includeAll?: boolean; + lanes?: LaneSummary[]; }; type AttentionStatusInput = { laneId: string; @@ -262,7 +263,7 @@ export function createAutoRebaseService(args: { const listStatuses = async (options?: ListStatusesOptions): Promise => { void maybeSweepRoots("listStatuses"); - const lanes = await laneService.list({ includeArchived: false }); + const lanes = options?.lanes ?? await laneService.list({ includeArchived: false }); if (disposed) return []; const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); const nowMs = Date.now(); diff --git a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.test.ts b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.test.ts index 150940aaf..110b89b93 100644 --- a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.test.ts +++ b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.test.ts @@ -519,4 +519,42 @@ describe("rebaseSuggestionService", () => { expect(saved.parentHeadSha).toBe("def456"); expect(saved.behindCount).toBe(3); }); + + it("caches short repeated suggestion scans", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-rebase-cache-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const listMock = vi.fn(async () => []); + + const service = createRebaseSuggestionService({ + db, + logger: createLogger(), + projectId: "proj-cache", + projectRoot: repoRoot, + laneService: { list: listMock } as any, + }); + + await service.listSuggestions(); + await service.listSuggestions(); + + expect(listMock).toHaveBeenCalledTimes(1); + }); + + it("force refresh bypasses the suggestion cache", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-rebase-force-cache-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const listMock = vi.fn(async () => []); + + const service = createRebaseSuggestionService({ + db, + logger: createLogger(), + projectId: "proj-force-cache", + projectRoot: repoRoot, + laneService: { list: listMock } as any, + }); + + await service.listSuggestions(); + await service.listSuggestions({ force: true }); + + expect(listMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts index da565ec7e..e3350888b 100644 --- a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts +++ b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts @@ -18,6 +18,13 @@ type StoredSuggestionState = { }; const KEY_PREFIX = "rebase:suggestion:"; +const SUGGESTION_CACHE_TTL_MS = 10_000; + +type ListSuggestionsOptions = { + force?: boolean; + lanes?: LaneSummary[]; + refreshRemoteTracking?: boolean; +}; function keyForLane(laneId: string): string { return `${KEY_PREFIX}${laneId}`; @@ -83,16 +90,23 @@ export function createRebaseSuggestionService(args: { db.setJson(keyForLane(state.laneId), state); }; + let cachedSuggestions: { atMs: number; suggestions: RebaseSuggestion[] } | null = null; + let suggestionsInFlight: Promise | null = null; + let suggestionsCacheGeneration = 0; + + const invalidateSuggestionsCache = () => { + cachedSuggestions = null; + suggestionsCacheGeneration += 1; + }; + const readRefHeadSha = async (ref: string): Promise => { const result = await runGit(["rev-parse", "--verify", ref], { cwd: projectRoot, timeoutMs: 10_000 }); return result.exitCode === 0 && result.stdout.trim() ? result.stdout.trim() : null; }; - const readBehindCount = async (args: { laneWorktreePath: string; baseHeadSha: string }): Promise => { - const laneHeadSha = await getHeadSha(args.laneWorktreePath); - if (!laneHeadSha) return 0; + const readBehindCount = async (args: { laneHeadSha: string; baseHeadSha: string }): Promise => { const result = await runGit( - ["rev-list", "--count", `${laneHeadSha}..${args.baseHeadSha}`], + ["rev-list", "--count", `${args.laneHeadSha}..${args.baseHeadSha}`], { cwd: projectRoot, timeoutMs: 10_000 } ); return result.exitCode === 0 ? Math.max(0, Number(result.stdout.trim()) || 0) : 0; @@ -104,18 +118,16 @@ export function createRebaseSuggestionService(args: { * trimmed. Returns an empty array on any git failure. */ const readBehindCommits = async (args: { - laneWorktreePath: string; + laneHeadSha: string; baseHeadSha: string; }): Promise => { - const laneHeadSha = await getHeadSha(args.laneWorktreePath); - if (!laneHeadSha) return []; const result = await runGit( [ "log", "-n", "20", "--pretty=format:%H%x1F%h%x1F%s%x1F%an%x1F%aI", - `${laneHeadSha}..${args.baseHeadSha}`, + `${args.laneHeadSha}..${args.baseHeadSha}`, ], { cwd: projectRoot, timeoutMs: 10_000 } ); @@ -139,13 +151,18 @@ export function createRebaseSuggestionService(args: { return out; }; - const resolvePrimaryParentHeadSha = async (parent: LaneSummary): Promise => { + const resolvePrimaryParentHeadSha = async ( + parent: LaneSummary, + options: { refreshRemoteTracking?: boolean } = {}, + ): Promise => { const parentBranch = parent.branchRef.trim(); if (!parentBranch) return null; - await fetchRemoteTrackingBranch({ - projectRoot, - targetBranch: parentBranch, - }).catch(() => {}); + if (options.refreshRemoteTracking) { + await fetchRemoteTrackingBranch({ + projectRoot, + targetBranch: parentBranch, + }).catch(() => {}); + } const remoteHeadSha = await readRefHeadSha(`origin/${parentBranch}`); if (remoteHeadSha) return remoteHeadSha; return getHeadSha(parent.worktreePath); @@ -155,6 +172,7 @@ export function createRebaseSuggestionService(args: { lane: LaneSummary, laneById: Map, primaryParentHeadByBranch: Map, + options: { refreshRemoteTracking?: boolean } = {}, ): Promise<{ parentLaneId: string; parentHeadSha: string; baseLabel: string | null; groupContext: string | null } | null> => { const queueOverride = await resolveQueueRebaseOverride({ db, @@ -183,10 +201,12 @@ export function createRebaseSuggestionService(args: { if (primaryParentHeadByBranch.has(parentBranch)) { parentHeadSha = primaryParentHeadByBranch.get(parentBranch) ?? null; } else { - await fetchRemoteTrackingBranch({ - projectRoot, - targetBranch: parentBranch, - }).catch(() => {}); + if (options.refreshRemoteTracking) { + await fetchRemoteTrackingBranch({ + projectRoot, + targetBranch: parentBranch, + }).catch(() => {}); + } parentHeadSha = await readRefHeadSha(`origin/${parentBranch}`); if (!parentHeadSha) { parentHeadSha = await getHeadSha(parent.worktreePath); @@ -211,7 +231,9 @@ export function createRebaseSuggestionService(args: { if (!baseRef) return null; if (lane.laneType === "primary") return null; const fetchTargetName = baseRef.replace(/^origin\//, ""); - await fetchRemoteTrackingBranch({ projectRoot, targetBranch: fetchTargetName }).catch(() => {}); + if (options.refreshRemoteTracking) { + await fetchRemoteTrackingBranch({ projectRoot, targetBranch: fetchTargetName }).catch(() => {}); + } const comparisonRef = baseRef.startsWith("origin/") ? baseRef : `origin/${fetchTargetName}`; const baseHeadSha = (await readRefHeadSha(comparisonRef)) @@ -225,14 +247,16 @@ export function createRebaseSuggestionService(args: { }; }; - const listSuggestions = async (): Promise => { - await fetchQueueTargetTrackingBranches({ - db, - projectId, - projectRoot, - }); + const computeSuggestions = async (options: ListSuggestionsOptions = {}): Promise => { + if (options.refreshRemoteTracking) { + await fetchQueueTargetTrackingBranches({ + db, + projectId, + projectRoot, + }); + } - const lanes = await laneService.list({ includeArchived: false }); + const lanes = options.lanes ?? await laneService.list({ includeArchived: false }); const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); const primaryParentHeadByBranch = new Map(); const prLaneIds = getPrLaneIds(); @@ -241,10 +265,14 @@ export function createRebaseSuggestionService(args: { const nowMs = Date.now(); for (const lane of lanes) { - const base = await resolveSuggestionBase(lane, laneById, primaryParentHeadByBranch); + const base = await resolveSuggestionBase(lane, laneById, primaryParentHeadByBranch, { + refreshRemoteTracking: options.refreshRemoteTracking === true, + }); if (!base) continue; + const laneHeadSha = await getHeadSha(lane.worktreePath); + if (!laneHeadSha) continue; const behindCount = await readBehindCount({ - laneWorktreePath: lane.worktreePath, + laneHeadSha, baseHeadSha: base.parentHeadSha, }); if (behindCount <= 0) continue; @@ -295,7 +323,7 @@ export function createRebaseSuggestionService(args: { let targetCommits: RebaseTargetCommit[] = []; try { targetCommits = await readBehindCommits({ - laneWorktreePath: lane.worktreePath, + laneHeadSha, baseHeadSha: base.parentHeadSha, }); } catch (err) { @@ -327,10 +355,37 @@ export function createRebaseSuggestionService(args: { }); }; - const emit = async () => { + const listSuggestions = async (options: ListSuggestionsOptions = {}): Promise => { + const nowMs = Date.now(); + if (!options.force && cachedSuggestions && nowMs - cachedSuggestions.atMs < SUGGESTION_CACHE_TTL_MS) { + return cachedSuggestions.suggestions; + } + if (!options.force && suggestionsInFlight) { + return suggestionsInFlight; + } + + const generation = suggestionsCacheGeneration; + const work = computeSuggestions(options); + if (!options.force) { + suggestionsInFlight = work; + } + try { + const suggestions = await work; + if (generation === suggestionsCacheGeneration) { + cachedSuggestions = { atMs: Date.now(), suggestions }; + } + return suggestions; + } finally { + if (suggestionsInFlight === work) { + suggestionsInFlight = null; + } + } + }; + + const emit = async (options: ListSuggestionsOptions = {}) => { if (!onEvent) return; try { - const suggestions = await listSuggestions(); + const suggestions = await listSuggestions({ ...options, force: true }); onEvent({ type: "rebase-suggestions-updated", computedAt: nowIso(), @@ -347,6 +402,7 @@ export function createRebaseSuggestionService(args: { const existing = loadState(laneId); if (existing) { + invalidateSuggestionsCache(); saveState({ ...existing, dismissedAt: nowIso() @@ -363,8 +419,10 @@ export function createRebaseSuggestionService(args: { const base = await resolveSuggestionBase(lane, laneById, primaryParentHeadByBranch); if (!base) throw new Error("Lane has no rebase suggestion to dismiss."); + const laneHeadSha = await getHeadSha(lane.worktreePath); + if (!laneHeadSha) throw new Error("Lane has no readable head."); const behindCount = await readBehindCount({ - laneWorktreePath: lane.worktreePath, + laneHeadSha, baseHeadSha: base.parentHeadSha, }); const next: StoredSuggestionState = { @@ -376,6 +434,7 @@ export function createRebaseSuggestionService(args: { deferredUntil: null, dismissedAt: nowIso() }; + invalidateSuggestionsCache(); saveState(next); void emit(); }; @@ -389,6 +448,7 @@ export function createRebaseSuggestionService(args: { const existing = loadState(laneId); if (existing) { + invalidateSuggestionsCache(); saveState({ ...existing, deferredUntil: until, @@ -406,8 +466,10 @@ export function createRebaseSuggestionService(args: { const base = await resolveSuggestionBase(lane, laneById, primaryParentHeadByBranch); if (!base) throw new Error("Lane has no rebase suggestion to defer."); + const laneHeadSha = await getHeadSha(lane.worktreePath); + if (!laneHeadSha) throw new Error("Lane has no readable head."); const behindCount = await readBehindCount({ - laneWorktreePath: lane.worktreePath, + laneHeadSha, baseHeadSha: base.parentHeadSha, }); const next: StoredSuggestionState = { @@ -419,6 +481,7 @@ export function createRebaseSuggestionService(args: { deferredUntil: until, dismissedAt: null }; + invalidateSuggestionsCache(); saveState(next); void emit(); }; @@ -439,7 +502,7 @@ export function createRebaseSuggestionService(args: { const lanes = await laneService.list({ includeArchived: false }); const parent = lanes.find((lane) => lane.id === parentId) ?? null; const resolvedParentHeadSha = parent?.laneType === "primary" - ? await resolvePrimaryParentHeadSha(parent) + ? await resolvePrimaryParentHeadSha(parent, { refreshRemoteTracking: true }) : (args.postHeadSha ?? "").trim(); if (!resolvedParentHeadSha) return; const directChildren = lanes.filter((lane) => lane.parentLaneId === parentId && lane.status.behind > 0); @@ -466,16 +529,17 @@ export function createRebaseSuggestionService(args: { deferredUntil: existing?.deferredUntil ?? null, dismissedAt: existing?.parentHeadSha === resolvedParentHeadSha ? existing.dismissedAt ?? null : null }; + invalidateSuggestionsCache(); saveState(next); } logger.info("rebaseSuggestions.parent_head_changed", { parentId, reason: args.reason, children: children.length }); - await emit(); + await emit({ refreshRemoteTracking: true }); }; return { listSuggestions, - refresh: emit, + refresh: () => emit({ refreshRemoteTracking: true }), dismiss, defer, onParentHeadChanged diff --git a/apps/desktop/src/main/services/memory/embeddingService.test.ts b/apps/desktop/src/main/services/memory/embeddingService.test.ts index a4e6ec76c..c49549841 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.test.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.test.ts @@ -215,31 +215,27 @@ describe("embeddingService", () => { })); }); - it("reports an installed local model path and loads from local cache during probe", async () => { + it("reports an installed local model path during probe without loading ONNX", async () => { const logger = createLogger(); const cacheDir = createTempCacheDir(); const installPath = writeInstalledModel(cacheDir); - const extractor = Object.assign( - vi.fn(async (text: string) => ({ data: buildVector(text), dims: [1, EXPECTED_EMBEDDING_DIMENSIONS] })), - { dispose: vi.fn(async () => {}) }, - ); - const pipeline = vi.fn(async (_task, _model, options?: { progress_callback?: (event: { file?: string; progress?: number }) => void }) => { - options?.progress_callback?.({ file: "tokenizer.json", progress: 100 }); - return extractor; + const pipeline = vi.fn(async () => { + throw new Error("pipeline should not run during cache probe"); }); + const loadRuntime = vi.fn(async () => ({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline, + })); const service = createEmbeddingService({ logger, cacheDir, - loadRuntime: async () => ({ - env: { - cacheDir: "", - allowRemoteModels: true, - allowLocalModels: true, - useFSCache: true, - }, - pipeline, - }), + loadRuntime, }); expect(service.getStatus()).toEqual(expect.objectContaining({ @@ -251,15 +247,15 @@ describe("embeddingService", () => { await service.probeCache(); - expect(pipeline).toHaveBeenCalledTimes(1); - expect(pipeline.mock.calls[0]?.[1]).toBe(installPath); - expect(pipeline.mock.calls[0]?.[2]).toBeDefined(); + expect(loadRuntime).not.toHaveBeenCalled(); + expect(pipeline).not.toHaveBeenCalled(); expect(service.getStatus()).toEqual(expect.objectContaining({ installState: "installed", installPath, - activity: "ready", - state: "ready", + activity: "idle", + state: "idle", })); + expect(service.isAvailable()).toBe(false); }); it("does not auto-download from a partial cache during startup probing", async () => { diff --git a/apps/desktop/src/main/services/memory/embeddingService.ts b/apps/desktop/src/main/services/memory/embeddingService.ts index 2634e327c..7c59fd5e9 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.ts @@ -507,9 +507,8 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { } /** - * Check if the model files exist in the cache dir and auto-load if so. - * Call this at startup so that previously-downloaded models are recognized - * without requiring the user to click "Download Model" again. + * Check if the model files exist in the cache dir without loading ONNX. + * Actual embedding work stays lazy until preload() or embed() is called. */ async function probeCache(): Promise { if (state === "ready" || state === "loading") return; @@ -522,10 +521,11 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { installPath, installState: install.installState, }); + emitStatus(); return; } - logger.info("memory.embedding.probe_cache", { modelId, cacheDir, installPath }); - await ensureExtractor({ localFilesOnly: true, installInspection: install }); + logger.info("memory.embedding.probe_cache_installed", { modelId, cacheDir, installPath }); + emitStatus(); } catch (error) { // Probe is best-effort — don't block startup logger.warn("memory.embedding.probe_cache_failed", { diff --git a/apps/desktop/src/main/services/memory/embeddingWorkerService.test.ts b/apps/desktop/src/main/services/memory/embeddingWorkerService.test.ts index 09f06dbda..408acf526 100644 --- a/apps/desktop/src/main/services/memory/embeddingWorkerService.test.ts +++ b/apps/desktop/src/main/services/memory/embeddingWorkerService.test.ts @@ -184,6 +184,32 @@ describe("embeddingWorkerService", () => { expect(rows.map((row) => row.memory_id)).toEqual([existing.id, missing.id].sort()); }); + it("does not embed candidate memories unless they are promoted or pinned", async () => { + const { db, worker, memoryService, embeddingService } = await createFixture({ attachQueueHook: false }); + + const candidate = memoryService.addCandidateMemory({ + projectId: "project-1", + scope: "project", + category: "fact", + content: "Tentative memory should stay out of embedding backfill.", + importance: "medium", + confidence: 0.7, + }); + + await worker.start(); + await worker.waitForIdle(); + + expect(embeddingService.embed).not.toHaveBeenCalled(); + expect(countEmbeddings(db)).toBe(0); + + memoryService.promoteMemory(candidate.id); + worker.runBackfill(); + await worker.waitForIdle(); + + expect(embeddingService.embed).toHaveBeenCalledTimes(1); + expect(countEmbeddings(db)).toBe(1); + }); + it("processes queued memories in bounded batches and stores 384-d blobs", async () => { const { db, worker, memoryService } = await createFixture({ idleBatchSize: 10, activeBatchSize: 10 }); diff --git a/apps/desktop/src/main/services/memory/embeddingWorkerService.ts b/apps/desktop/src/main/services/memory/embeddingWorkerService.ts index 82290c3b0..5f5e297d6 100644 --- a/apps/desktop/src/main/services/memory/embeddingWorkerService.ts +++ b/apps/desktop/src/main/services/memory/embeddingWorkerService.ts @@ -15,6 +15,7 @@ type MemoryRow = { id: string; content: string | null; status: string | null; + pinned: number | boolean | null; }; type CreateEmbeddingWorkerServiceOpts = { @@ -143,9 +144,6 @@ export function createEmbeddingWorkerService(opts: CreateEmbeddingWorkerServiceO } function queueMemory(memoryId: string) { - // Don't gate on isAvailable() — items should queue during model loading - // and get processed once the model is ready. The processing loop handles - // unavailability via embed()'s ensureExtractor() which awaits loading. const normalized = String(memoryId ?? "").trim(); if (!normalized || queuedIds.has(normalized)) return; queuedIds.add(normalized); @@ -162,7 +160,7 @@ export function createEmbeddingWorkerService(opts: CreateEmbeddingWorkerServiceO ON e.memory_id = m.id AND e.embedding_model = ? WHERE m.project_id = ? - AND m.status != 'archived' + AND (m.status = 'promoted' OR m.pinned = 1) AND e.id IS NULL ORDER BY m.created_at ASC `, @@ -175,7 +173,7 @@ export function createEmbeddingWorkerService(opts: CreateEmbeddingWorkerServiceO const placeholders = batchIds.map(() => "?").join(", "); const rows = db.all( ` - SELECT id, content, status + SELECT id, content, status, pinned FROM unified_memories WHERE project_id = ? AND id IN (${placeholders}) @@ -247,7 +245,9 @@ export function createEmbeddingWorkerService(opts: CreateEmbeddingWorkerServiceO for (const row of rows) { const memoryId = String(row.id ?? "").trim(); if (!memoryId) continue; - if (String(row.status ?? "").trim() === "archived") continue; + const status = String(row.status ?? "").trim(); + const pinned = row.pinned === true || Number(row.pinned ?? 0) === 1; + if (status !== "promoted" && !pinned) continue; try { const vector = await embeddingService.embed(String(row.content ?? "")); @@ -282,8 +282,6 @@ export function createEmbeddingWorkerService(opts: CreateEmbeddingWorkerServiceO async function start() { if (started) return getStatus(); started = true; - // Always run backfill — items queue regardless of model availability. - // The processing loop handles unavailability via embed()'s ensureExtractor(). for (const id of listBackfillIds()) { queueMemory(id); } diff --git a/apps/desktop/src/main/services/memory/knowledgeCaptureService.test.ts b/apps/desktop/src/main/services/memory/knowledgeCaptureService.test.ts index db0efd30e..0d8e07872 100644 --- a/apps/desktop/src/main/services/memory/knowledgeCaptureService.test.ts +++ b/apps/desktop/src/main/services/memory/knowledgeCaptureService.test.ts @@ -265,6 +265,18 @@ describe("knowledgeCaptureService", () => { createdAt: null, updatedAt: null, }, + { + id: "comment-copilot-command", + author: "maintainer", + authorAvatarUrl: null, + body: "@copilot review but do not make fixes", + source: "issue", + url: null, + path: null, + line: null, + createdAt: null, + updatedAt: null, + }, ], getReviews: async () => [ { diff --git a/apps/desktop/src/main/services/memory/knowledgeCaptureService.ts b/apps/desktop/src/main/services/memory/knowledgeCaptureService.ts index 094513325..f8e7f2672 100644 --- a/apps/desktop/src/main/services/memory/knowledgeCaptureService.ts +++ b/apps/desktop/src/main/services/memory/knowledgeCaptureService.ts @@ -74,11 +74,15 @@ function hashText(value: string): string { } const GENERIC_PR_FEEDBACK_PATTERNS = [ + /^@(?:copilot|coderabbit)\b/i, + /^acknowledged\b/i, + /^thanks?\b/i, /^preview deployment\b/i, /^learn more about\b/i, /^read more about\b/i, /^see more\b/i, /^click here\b/i, + /\bdo not make fixes\b/i, ]; const DERIVABLE_PR_FEEDBACK_PATTERNS = [ @@ -445,7 +449,8 @@ export function createKnowledgeCaptureService(args: { const distinctOrigins = new Set( matches.map((item) => cleanText(item.sourceRunId || item.sourceId || item.id)).filter(Boolean), ); - if (distinctOrigins.size < 3) return; + const repeatedObservations = matches.some((item) => item.observationCount >= 3); + if (distinctOrigins.size < 3 && !repeatedObservations) return; const canonical = [...matches].sort((left, right) => (right.observationCount - left.observationCount) @@ -489,6 +494,7 @@ export function createKnowledgeCaptureService(args: { metadata: { canonicalMemoryId: result.id, distinctOrigins: distinctOrigins.size, + observationCount: canonical.observationCount, }, }); }; diff --git a/apps/desktop/src/main/services/memory/memoryService.ts b/apps/desktop/src/main/services/memory/memoryService.ts index 17fdc47a8..cb659447b 100644 --- a/apps/desktop/src/main/services/memory/memoryService.ts +++ b/apps/desktop/src/main/services/memory/memoryService.ts @@ -292,13 +292,11 @@ export function resolveAgentMemoryWritePolicy(args: { writeGateMode?: WriteGateMode; }): AgentMemoryWritePolicy { const pinned = args.pin === true; - const promoted = pinned || args.writeGateMode === "strict"; - const tier: MemoryTier = pinned ? 1 : promoted ? 2 : 3; return { - status: promoted ? "promoted" : "candidate", - tier, - confidence: promoted ? 1 : 0.6, + status: "promoted", + tier: pinned ? 1 : 2, + confidence: 1, }; } @@ -587,7 +585,6 @@ export function createMemoryService(db: AdeDb, serviceOpts: CreateMemoryServiceO AND scope = ? AND COALESCE(scope_owner_id, '') = ? AND status != 'archived' - AND tier IN (1, 2) ORDER BY updated_at DESC LIMIT 120 `, diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.ts b/apps/desktop/src/main/services/projects/projectIconResolver.ts index c4ea0f86f..48f368434 100644 --- a/apps/desktop/src/main/services/projects/projectIconResolver.ts +++ b/apps/desktop/src/main/services/projects/projectIconResolver.ts @@ -167,6 +167,22 @@ type IconSearchRootsCacheEntry = { }; const iconSearchRootsCache = new Map(); +const PROJECT_ICON_RESULT_CACHE_MAX = 64; +const PROJECT_ICON_RESULT_CACHE_TTL_MS = 5 * 60_000; + +type ProjectIconResultCacheEntry = { + rootMtimeMs: number; + appsMtimeMs: number; + packagesMtimeMs: number; + configMtimeMs: number; + sourcePath: string | null; + sourceMtimeMs: number; + sourceSize: number; + expiresAtMs: number; + value: ProjectIcon; +}; + +const projectIconResultCache = new Map(); function dirMtimeMs(absPath: string): number { try { @@ -176,6 +192,46 @@ function dirMtimeMs(absPath: string): number { } } +function fileSignature(absPath: string | null): { mtimeMs: number; size: number } { + if (!absPath) return { mtimeMs: -1, size: -1 }; + try { + const stat = fs.statSync(absPath); + return stat.isFile() + ? { mtimeMs: stat.mtimeMs, size: stat.size } + : { mtimeMs: -1, size: -1 }; + } catch { + return { mtimeMs: -1, size: -1 }; + } +} + +function projectIconResultCacheKey(root: string, options: { iconPathOverride?: string | null }): string { + const overrideKey = Object.prototype.hasOwnProperty.call(options, "iconPathOverride") + ? `override:${options.iconPathOverride ?? "null"}` + : "auto"; + return `${root}\0${overrideKey}`; +} + +function setProjectIconResultCache(key: string, entry: ProjectIconResultCacheEntry): void { + if (projectIconResultCache.has(key)) { + projectIconResultCache.delete(key); + } else if (projectIconResultCache.size >= PROJECT_ICON_RESULT_CACHE_MAX) { + const oldestKey = projectIconResultCache.keys().next().value; + if (oldestKey !== undefined) { + projectIconResultCache.delete(oldestKey); + } + } + projectIconResultCache.set(key, entry); +} + +function clearProjectIconResultCache(projectRoot: string): void { + const root = path.resolve(projectRoot); + for (const key of projectIconResultCache.keys()) { + if (key === root || key.startsWith(`${root}\0`)) { + projectIconResultCache.delete(key); + } + } +} + // Resolving a project icon scans the project root and every first-level child // of `apps/` and `packages/`. On large monorepos the project-tab render fan-out // turned into hundreds of `readdirSync` calls per refresh; cache the result @@ -417,12 +473,14 @@ export function setProjectIconOverride(projectRoot: string, iconPath: string): P const relativeIconPath = toProjectRelative(root, resolvedIconPath); writeProjectIconPathOverride(root, relativeIconPath); + clearProjectIconResultCache(root); return resolveProjectIcon(root, { iconPathOverride: relativeIconPath }); } export function removeProjectIconOverride(projectRoot: string): ProjectIcon { const root = path.resolve(projectRoot); writeProjectIconPathOverride(root, null); + clearProjectIconResultCache(root); return resolveProjectIcon(root, { iconPathOverride: null }); } @@ -430,11 +488,59 @@ export function resolveProjectIcon( projectRoot: string, options: { iconPathOverride?: string | null } = {}, ): ProjectIcon { - const iconPath = resolveProjectIconPath(projectRoot, options); - if (!iconPath) return { dataUrl: null, sourcePath: null, mimeType: null }; + const root = path.resolve(projectRoot); + const cacheKey = projectIconResultCacheKey(root, options); + const rootMtimeMs = dirMtimeMs(root); + const appsMtimeMs = dirMtimeMs(path.join(root, "apps")); + const packagesMtimeMs = dirMtimeMs(path.join(root, "packages")); + const configMtimeMs = dirMtimeMs(path.join(root, ".ade", "ade.yaml")); + const cached = projectIconResultCache.get(cacheKey); + if ( + cached + && cached.expiresAtMs > Date.now() + && cached.rootMtimeMs === rootMtimeMs + && cached.appsMtimeMs === appsMtimeMs + && cached.packagesMtimeMs === packagesMtimeMs + && cached.configMtimeMs === configMtimeMs + ) { + const sourceSignature = fileSignature(cached.sourcePath); + if (sourceSignature.mtimeMs === cached.sourceMtimeMs && sourceSignature.size === cached.sourceSize) { + projectIconResultCache.delete(cacheKey); + projectIconResultCache.set(cacheKey, cached); + return cached.value; + } + } + + const cacheValue = (value: ProjectIcon, sourcePath: string | null, sourceMtimeMs = -1, sourceSize = -1): ProjectIcon => { + setProjectIconResultCache(cacheKey, { + rootMtimeMs, + appsMtimeMs, + packagesMtimeMs, + configMtimeMs, + sourcePath, + sourceMtimeMs, + sourceSize, + expiresAtMs: Date.now() + PROJECT_ICON_RESULT_CACHE_TTL_MS, + value, + }); + return value; + }; + + const iconPath = resolveProjectIconPath(root, options); + if (!iconPath) { + return cacheValue({ dataUrl: null, sourcePath: null, mimeType: null }, null); + } const mimeType = mimeTypeForIconPath(iconPath); - if (!mimeType) return { dataUrl: null, sourcePath: null, mimeType: null }; + if (!mimeType) { + const sourceSignature = fileSignature(iconPath); + return cacheValue( + { dataUrl: null, sourcePath: iconPath, mimeType: null }, + iconPath, + sourceSignature.mtimeMs, + sourceSignature.size, + ); + } // resolveProjectIconPath already returned a realpath inside the project // root, but defensively swallow any read/stat failure (e.g. a race that @@ -443,15 +549,20 @@ export function resolveProjectIcon( try { const stat = fs.statSync(iconPath); if (stat.size > ICON_MAX_BYTES) { - return { dataUrl: null, sourcePath: iconPath, mimeType }; + return cacheValue({ dataUrl: null, sourcePath: iconPath, mimeType }, iconPath, stat.mtimeMs, stat.size); } const data = fs.readFileSync(iconPath); - return { - dataUrl: `data:${mimeType};base64,${data.toString("base64")}`, - sourcePath: iconPath, - mimeType, - }; + return cacheValue( + { + dataUrl: `data:${mimeType};base64,${data.toString("base64")}`, + sourcePath: iconPath, + mimeType, + }, + iconPath, + stat.mtimeMs, + stat.size, + ); } catch { - return { dataUrl: null, sourcePath: null, mimeType: null }; + return cacheValue({ dataUrl: null, sourcePath: null, mimeType: null }, null); } } diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index ae2c08640..0b744ad66 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -1450,15 +1450,48 @@ describe("ptyService", () => { describe("PTY data handling", () => { it("broadcasts data events when the PTY emits data", async () => { - const { service, mockPty, broadcastData } = createHarness(); - const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); - mockPty._emitter.emit("data", "hello world"); - expect(broadcastData).toHaveBeenCalledWith({ - ptyId, - sessionId, - projectRoot: "/tmp/test-project", - data: "hello world", - }); + vi.useFakeTimers(); + try { + const { service, mockPty, broadcastData } = createHarness(); + const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + mockPty._emitter.emit("data", "hello world"); + expect(broadcastData).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(16); + expect(broadcastData).toHaveBeenCalledWith({ + ptyId, + sessionId, + projectRoot: "/tmp/test-project", + data: "hello world", + }); + } finally { + vi.useRealTimers(); + } + }); + + it("coalesces rapid PTY data chunks and flushes before exit", async () => { + vi.useFakeTimers(); + try { + const { service, mockPty, broadcastData, broadcastExit } = createHarness(); + const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + mockPty._emitter.emit("data", "hello "); + mockPty._emitter.emit("data", "world"); + mockPty._emitter.emit("exit", { exitCode: 0 }); + + expect(broadcastData).toHaveBeenCalledWith({ + ptyId, + sessionId, + projectRoot: "/tmp/test-project", + data: "hello world", + }); + expect(broadcastExit).toHaveBeenCalledWith({ + ptyId, + sessionId, + projectRoot: "/tmp/test-project", + exitCode: 0, + }); + } finally { + vi.useRealTimers(); + } }); it("closes entry and broadcasts exit when PTY exits", async () => { @@ -1597,6 +1630,44 @@ describe("ptyService", () => { } }); + it("cools down repeated missing resume target backfills during session-list hydration", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-04-15T22:00:00.000Z")); + + const { service, sessionService, logger } = createHarness(); + sessionService.create({ + sessionId: "session-missing", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: "2026-04-15T21:30:00.000Z", + transcriptPath: "/tmp/worktree/.ade/transcripts/session-missing.log", + toolType: "codex", + }); + + await service.ensureResumeTargets(["session-missing"]); + await service.ensureResumeTargets(["session-missing"]); + + expect(sessionService.readTranscriptTail).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + "pty.resume_target_missing", + expect.objectContaining({ sessionId: "session-missing", reason: "session-list" }), + ); + + await vi.advanceTimersByTimeAsync(10 * 60_000 - 1); + await service.ensureResumeTargets(["session-missing"]); + expect(sessionService.readTranscriptTail).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await service.ensureResumeTargets(["session-missing"]); + expect(sessionService.readTranscriptTail).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + it("captures a fresh Codex storage target for a new launch without choosing older same-cwd sessions", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index c1cbcc733..68cd284ee 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -55,6 +55,9 @@ function shouldScheduleOutputSnippetTitle(tool: TerminalToolType | null): boolea const CLI_USER_TITLE_SEED_MIN_LEN = 3; const CLI_USER_TITLE_SEED_MAX_LEN = 180; const CLI_USER_TITLE_FALLBACK_MAX_LEN = 72; +const PTY_DATA_BATCH_INTERVAL_MS = 16; +const PTY_DATA_BATCH_MAX_CHARS = 64 * 1024; +const PTY_DATA_SUMMARY_INTERVAL_MS = 10_000; function sanitizeCliUserTitleSeed(raw: string): string { const stripped = stripAnsi(raw) @@ -140,6 +143,11 @@ type PtyEntry = { disposed: boolean; createdAt: number; cleanupPaths: string[]; + lastResizeCols: number | null; + lastResizeRows: number | null; + pendingDataChunks: string[]; + pendingDataChars: number; + pendingDataTimer: ReturnType | null; /** Output-snippet title timer (skipped for interactive Claude/Codex; see CLI user-title path). */ aiTitleTimer: ReturnType | null; cliUserTitleLineBuffer: string; @@ -264,6 +272,7 @@ function inferSessionCwdFromTranscriptPath(transcriptPath: string | null | undef const MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024; const TRANSCRIPT_LIMIT_NOTICE = "\n[ADE] transcript limit reached (8MB). Further output omitted.\n"; +const RESUME_TARGET_MISSING_COOLDOWN_MS = 10 * 60_000; export function createPtyService({ projectRoot, @@ -308,8 +317,15 @@ export function createPtyService({ const exitListeners = new Set(); const terminalChatSessions = new Map(); const activeTerminalByChatSession = new Map(); + const missingResumeTargetBackfillFailures = new Map(); /** Timers for auto-closing tool-typed PTYs when the CLI tool exits back to shell prompt */ const toolAutoCloseTimers = new Map>(); + let ptyDataSummaryTimer: ReturnType | null = null; + let ptyDataSummaryStartedAt = Date.now(); + let ptyDataChunkCount = 0; + let ptyDataBatchCount = 0; + let ptyDataCharCount = 0; + let ptyDataMaxBatchChars = 0; const getSessionIntelligence = () => { const ai = projectConfigService?.get().effective.ai; @@ -431,6 +447,36 @@ export function createPtyService({ state.idleTimer = null; }; + const flushPtyDataSummary = () => { + if (ptyDataSummaryTimer) { + clearTimeout(ptyDataSummaryTimer); + ptyDataSummaryTimer = null; + } + if (ptyDataChunkCount > 0 || ptyDataBatchCount > 0) { + const intervalMs = Math.max(1, Date.now() - ptyDataSummaryStartedAt); + logger.info("pty.data.summary", { + intervalMs, + chunks: ptyDataChunkCount, + batches: ptyDataBatchCount, + chars: ptyDataCharCount, + avgChunksPerBatch: ptyDataBatchCount > 0 ? Math.round((ptyDataChunkCount / ptyDataBatchCount) * 10) / 10 : 0, + maxBatchChars: ptyDataMaxBatchChars, + activePtys: ptys.size, + listeners: dataListeners.size, + }); + } + ptyDataSummaryStartedAt = Date.now(); + ptyDataChunkCount = 0; + ptyDataBatchCount = 0; + ptyDataCharCount = 0; + ptyDataMaxBatchChars = 0; + }; + + const schedulePtyDataSummary = () => { + if (ptyDataSummaryTimer) return; + ptyDataSummaryTimer = setTimeout(flushPtyDataSummary, PTY_DATA_SUMMARY_INTERVAL_MS); + }; + const setRuntimeState = (sessionId: string, nextState: TerminalRuntimeState, opts?: { touch?: boolean }) => { const now = Date.now(); const prev = runtimeStates.get(sessionId); @@ -821,11 +867,20 @@ export function createPtyService({ const effectiveToolType = preferredToolType ?? session.toolType ?? null; if (!isTrackedCliToolType(effectiveToolType)) return false; if (session.resumeMetadata?.targetId?.trim()) return true; + const recentMissing = missingResumeTargetBackfillFailures.get(sessionId); + if ( + reason === "session-list" + && recentMissing?.toolType === effectiveToolType + && Date.now() - recentMissing.checkedAtMs < RESUME_TARGET_MISSING_COOLDOWN_MS + ) { + return false; + } // Strategy 1: Try parsing the transcript for an explicit resume command const transcript = await sessionService.readTranscriptTail(session.transcriptPath, 220_000); const detected = extractResumeCommandFromOutput(transcript, effectiveToolType); if (detected) { + missingResumeTargetBackfillFailures.delete(sessionId); sessionService.setResumeCommand(sessionId, detected); logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason, source: "transcript" }); return true; @@ -838,6 +893,7 @@ export function createPtyService({ const claudeSessionId = resolveClaudeSessionIdFromStorage(cwd); if (claudeSessionId) { const resumeCmd = `claude --resume ${claudeSessionId}`; + missingResumeTargetBackfillFailures.delete(sessionId); sessionService.setResumeCommand(sessionId, resumeCmd); logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason, source: "claude-storage", claudeSessionId }); return true; @@ -857,12 +913,19 @@ export function createPtyService({ }); if (codexSessionId) { const resumeCmd = `codex resume ${codexSessionId}`; + missingResumeTargetBackfillFailures.delete(sessionId); sessionService.setResumeCommand(sessionId, resumeCmd); logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason, source: "codex-storage", codexSessionId }); return true; } } + if (reason === "session-list") { + missingResumeTargetBackfillFailures.set(sessionId, { + toolType: effectiveToolType, + checkedAtMs: Date.now(), + }); + } logger.warn("pty.resume_target_missing", { sessionId, toolType: effectiveToolType, reason }); return false; }; @@ -1110,6 +1173,7 @@ export function createPtyService({ } }); + flushQueuedPtyData(entry, { ptyId, sessionId: entry.sessionId }); emitPtyExit(entry, { ptyId, sessionId: entry.sessionId, exitCode }); ptys.delete(ptyId); }; @@ -1198,7 +1262,7 @@ export function createPtyService({ } }; - const emitPtyData = (entry: PtyEntry, event: PtyDataEvent) => { + const emitPtyDataNow = (entry: PtyEntry, event: PtyDataEvent) => { const scopedEvent = { ...event, projectRoot }; broadcastData(scopedEvent); const enriched = { ...scopedEvent, laneId: entry.laneId }; @@ -1211,6 +1275,42 @@ export function createPtyService({ } }; + const clearPendingDataTimer = (entry: PtyEntry) => { + if (!entry.pendingDataTimer) return; + clearTimeout(entry.pendingDataTimer); + entry.pendingDataTimer = null; + }; + + const flushQueuedPtyData = (entry: PtyEntry, ids: { ptyId: string; sessionId: string }) => { + clearPendingDataTimer(entry); + if (entry.pendingDataChunks.length === 0) return; + const data = entry.pendingDataChunks.join(""); + entry.pendingDataChunks.length = 0; + entry.pendingDataChars = 0; + if (!data) return; + ptyDataBatchCount += 1; + ptyDataMaxBatchChars = Math.max(ptyDataMaxBatchChars, data.length); + emitPtyDataNow(entry, { ...ids, data }); + }; + + const enqueuePtyData = (entry: PtyEntry, event: PtyDataEvent) => { + if (!event.data) return; + ptyDataChunkCount += 1; + ptyDataCharCount += event.data.length; + schedulePtyDataSummary(); + entry.pendingDataChunks.push(event.data); + entry.pendingDataChars += event.data.length; + const ids = { ptyId: event.ptyId, sessionId: event.sessionId }; + if (entry.pendingDataChars >= PTY_DATA_BATCH_MAX_CHARS) { + flushQueuedPtyData(entry, ids); + return; + } + if (entry.pendingDataTimer) return; + entry.pendingDataTimer = setTimeout(() => { + flushQueuedPtyData(entry, ids); + }, PTY_DATA_BATCH_INTERVAL_MS); + }; + const emitPtyExit = (entry: Pick, event: PtyExitEvent) => { const scopedEvent = { ...event, projectRoot }; broadcastExit(scopedEvent); @@ -1576,6 +1676,11 @@ export function createPtyService({ disposed: false, createdAt: Date.now(), cleanupPaths, + lastResizeCols: null, + lastResizeRows: null, + pendingDataChunks: [], + pendingDataChars: 0, + pendingDataTimer: null, aiTitleTimer: null, cliUserTitleLineBuffer: "", cliUserTitleCommitted: false, @@ -1596,7 +1701,7 @@ export function createPtyService({ pty.onData((data) => { writeTranscript(entry, data); updatePreviewThrottled(entry, data); - emitPtyData(entry, { ptyId, sessionId, data }); + enqueuePtyData(entry, { ptyId, sessionId, data }); const prevState = runtimeStates.get(sessionId)?.state ?? "running"; const runtimeState = runtimeStateFromOsc133Chunk(data, prevState); @@ -1898,8 +2003,11 @@ export function createPtyService({ const entry = ptys.get(ptyId); if (!entry) return; const safe = clampDims(cols, rows); + if (entry.lastResizeCols === safe.cols && entry.lastResizeRows === safe.rows) return; try { entry.pty.resize(safe.cols, safe.rows); + entry.lastResizeCols = safe.cols; + entry.lastResizeRows = safe.rows; } catch (err) { logger.warn("pty.resize_failed", { ptyId, err: String(err) }); } @@ -1941,8 +2049,11 @@ export function createPtyService({ ); if (!entry) return false; const safe = clampDims(cols, rows); + if (entry.lastResizeCols === safe.cols && entry.lastResizeRows === safe.rows) return true; try { entry.pty.resize(safe.cols, safe.rows); + entry.lastResizeCols = safe.cols; + entry.lastResizeRows = safe.rows; return true; } catch (err) { logger.warn("pty.resize_by_session_failed", { sessionId, err: String(err) }); @@ -2009,6 +2120,7 @@ export function createPtyService({ entry.aiTitleTimer = null; } clearToolAutoCloseTimer(ptyId); + flushQueuedPtyData(entry, { ptyId, sessionId: entry.sessionId }); cleanupEntryPaths(entry); try { entry.pty.kill(); diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index b7e32845a..fbceb6c5d 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -5,6 +5,7 @@ import type { SyncAddressCandidate, SyncDesktopConnectionDraft, SyncDeviceRuntimeState, + SyncGetStatusArgs, SyncPairingConnectInfo, SyncPairingQrPayload, SyncProjectCatalogPayload, @@ -165,6 +166,17 @@ const RUNNING_PROCESS_STATES = new Set(["starting", "running", "degraded"]); const CHAT_TOOL_TYPES = new Set(["codex-chat", "claude-chat", "opencode-chat"]); const SYNC_HOST_PORT_RETRY_WINDOW = 12; const LOCAL_LANE_PRESENCE_HEARTBEAT_MS = 30_000; +const TRANSFER_READINESS_CACHE_MS = 15_000; + +function buildSkippedTransferReadiness(): SyncTransferReadiness { + return { + ready: false, + blockers: [], + survivableState: [ + "Transfer readiness was skipped for this lightweight sync status request.", + ], + }; +} function sanitizeDraft( raw: unknown, @@ -371,6 +383,8 @@ export function createSyncService(args: SyncServiceArgs) { let initialized = false; let hostStartupEnabled = args.hostStartupEnabled !== false; let hostDiscoveryEnabled = args.hostDiscoveryEnabled !== false; + let transferReadinessCache: { value: SyncTransferReadiness; expiresAtMs: number } | null = null; + let transferReadinessInFlight: Promise | null = null; const forceHostRole = args.forceHostRole === true; const isCrdtSyncAvailable = (): boolean => args.db.sync.isAvailable?.() !== false; const assertPhonePairingAvailable = (): void => { @@ -692,7 +706,7 @@ export function createSyncService(args: SyncServiceArgs) { }); }; - const getTransferReadiness = async (): Promise => { + const computeTransferReadiness = async (): Promise => { const blockers: SyncTransferBlocker[] = []; for (const mission of args.missionService.list({ @@ -768,6 +782,26 @@ export function createSyncService(args: SyncServiceArgs) { }; }; + const getTransferReadiness = async (options?: { force?: boolean }): Promise => { + const now = Date.now(); + if (!options?.force && transferReadinessCache && transferReadinessCache.expiresAtMs > now) { + return transferReadinessCache.value; + } + if (!options?.force && transferReadinessInFlight) return transferReadinessInFlight; + transferReadinessInFlight = computeTransferReadiness() + .then((value) => { + transferReadinessCache = { + value, + expiresAtMs: Date.now() + TRANSFER_READINESS_CACHE_MS, + }; + return value; + }) + .finally(() => { + transferReadinessInFlight = null; + }); + return transferReadinessInFlight; + }; + const service = { async initialize(): Promise { if (initialized) return; @@ -782,7 +816,7 @@ export function createSyncService(args: SyncServiceArgs) { return initializingPromise; }, - async getStatus(): Promise { + async getStatus(options?: SyncGetStatusArgs): Promise { const localDevice = deviceRegistryService.ensureLocalDevice(); const cluster = deviceRegistryService.getClusterState(); const savedDraft = readSavedDraft(); @@ -827,7 +861,9 @@ export function createSyncService(args: SyncServiceArgs) { : "Tailnet discovery is only published by the host desktop.", ), client, - transferReadiness: await getTransferReadiness(), + transferReadiness: options?.includeTransferReadiness === false + ? (transferReadinessCache?.value ?? buildSkippedTransferReadiness()) + : await getTransferReadiness({ force: options?.forceTransferReadiness === true }), survivableStateText: crdtSyncAvailable ? "Paused and idle state will remain available on the new host." @@ -954,11 +990,11 @@ export function createSyncService(args: SyncServiceArgs) { }, async getTransferReadiness(): Promise { - return await getTransferReadiness(); + return await getTransferReadiness({ force: true }); }, async transferBrainToLocal(): Promise { - const current = await this.getStatus(); + const current = await this.getStatus({ forceTransferReadiness: true }); if (current.role === "brain") return current; if (!current.transferReadiness.ready) { throw new Error( diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index a33850b27..6bf9edd04 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -149,6 +149,7 @@ import type { SyncDesktopConnectionDraft, SyncDeviceRecord, SyncDeviceRuntimeState, + SyncGetStatusArgs, SyncPeerDeviceType, SyncRoleSnapshot, SyncStatusEventPayload, @@ -723,7 +724,7 @@ declare global { updateConfig: (config: Partial) => Promise; }; sync: { - getStatus: () => Promise; + getStatus: (args?: SyncGetStatusArgs) => Promise; refreshDiscovery: () => Promise; listDevices: () => Promise; updateLocalDevice: (args: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 047d41e8b..2cd6d496b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -52,6 +52,7 @@ import type { SyncDesktopConnectionDraft, SyncDeviceRecord, SyncDeviceRuntimeState, + SyncGetStatusArgs, SyncPeerDeviceType, SyncRoleSnapshot, SyncStatusEventPayload, @@ -654,6 +655,294 @@ import type { FeedbackSubmitDraftArgs, } from "../shared/types"; +type ShortIpcCache = { + clear: () => void; + get: (opts?: { force?: boolean }) => Promise; +}; + +function createShortIpcCache(loader: () => Promise, ttlMs: number): ShortIpcCache { + let value: T | undefined; + let promise: Promise | null = null; + let expiresAt = 0; + + return { + clear: () => { + value = undefined; + promise = null; + expiresAt = 0; + }, + get: async (opts?: { force?: boolean }) => { + const now = Date.now(); + if (!opts?.force) { + if (value !== undefined && expiresAt > now) return value; + if (promise) return promise; + } + + promise = loader() + .then((next) => { + value = next; + expiresAt = Date.now() + ttlMs; + return next; + }) + .finally(() => { + promise = null; + }); + return promise; + }, + }; +} + +function createKeyedShortIpcCache( + loader: (key: string) => Promise, + ttlMs: number, +): { + clear: (key?: string) => void; + get: (key: string, opts?: { force?: boolean }) => Promise; +} { + const caches = new Map>(); + const getCache = (key: string): ShortIpcCache => { + const existing = caches.get(key); + if (existing) return existing; + const cache = createShortIpcCache(() => loader(key), ttlMs); + caches.set(key, cache); + return cache; + }; + + return { + clear: (key?: string) => { + if (key == null) { + caches.clear(); + return; + } + caches.delete(key); + }, + get: (key: string, opts?: { force?: boolean }) => getCache(key).get(opts), + }; +} + +function serializeIpcCacheArgs(value: unknown): string { + return JSON.stringify(value ?? {}) ?? "{}"; +} + +function parseIpcCacheArgs(key: string, fallback: T): T { + try { + return JSON.parse(key) as T; + } catch { + return fallback; + } +} + +const projectConfigSnapshotCache = createShortIpcCache( + () => ipcRenderer.invoke(IPC.projectConfigGet), + 1_000, +); + +const aiStatusCache = (() => { + let value: AiSettingsStatus | undefined; + let promise: Promise | null = null; + let expiresAt = 0; + let includesOpenCodeInventory = false; + let promiseIncludesOpenCodeInventory = false; + + const clear = () => { + value = undefined; + promise = null; + expiresAt = 0; + includesOpenCodeInventory = false; + promiseIncludesOpenCodeInventory = false; + }; + + const get = async (key: string): Promise => { + const args = parseIpcCacheArgs<{ refreshOpenCodeInventory?: boolean }>(key, {}); + const wantsOpenCodeInventory = args.refreshOpenCodeInventory === true; + const now = Date.now(); + if ( + value !== undefined + && expiresAt > now + && (!wantsOpenCodeInventory || includesOpenCodeInventory) + ) { + return value; + } + if ( + promise + && (!wantsOpenCodeInventory || promiseIncludesOpenCodeInventory) + ) { + return promise; + } + + promiseIncludesOpenCodeInventory = wantsOpenCodeInventory; + const request = ipcRenderer.invoke(IPC.aiGetStatus, { + refreshOpenCodeInventory: wantsOpenCodeInventory, + }).then((status: AiSettingsStatus) => { + if (promise === request) { + value = status; + expiresAt = Date.now() + 10_000; + includesOpenCodeInventory = wantsOpenCodeInventory; + } + return status; + }).finally(() => { + if (promise === request) { + promise = null; + promiseIncludesOpenCodeInventory = false; + } + }); + promise = request; + return request; + }; + + return { clear, get }; +})(); + +const githubStatusCache = createShortIpcCache( + () => ipcRenderer.invoke(IPC.githubGetStatus, {}), + 30_000, +); + +const lanesListCache = createKeyedShortIpcCache( + (key) => ipcRenderer.invoke(IPC.lanesList, parseIpcCacheArgs(key, {})), + 2_000, +); + +const lanesListSnapshotsCache = createKeyedShortIpcCache( + (key) => ipcRenderer.invoke(IPC.lanesListSnapshots, parseIpcCacheArgs(key, {})), + 2_000, +); + +const sessionDeltaCache = createKeyedShortIpcCache( + (sessionId) => ipcRenderer.invoke(IPC.sessionsGetDelta, { sessionId }), + 1_000, +); + +const agentChatSummaryCache = createKeyedShortIpcCache( + (sessionId) => ipcRenderer.invoke(IPC.agentChatGetSummary, { sessionId }), + 1_000, +); + +const iosSimulatorStatusCache = createShortIpcCache( + () => ipcRenderer.invoke(IPC.iosSimulatorGetStatus), + 2_000, +); + +const iosSimulatorDevicesCache = createShortIpcCache( + () => ipcRenderer.invoke(IPC.iosSimulatorListDevices), + 2_000, +); + +const appControlStatusCache = createShortIpcCache( + () => ipcRenderer.invoke(IPC.appControlGetStatus), + 1_000, +); + +const computerUseOwnerSnapshotCache = createKeyedShortIpcCache( + (key) => ipcRenderer.invoke( + IPC.computerUseGetOwnerSnapshot, + parseIpcCacheArgs(key, {} as ComputerUseOwnerSnapshotArgs), + ), + 2_000, +); + +const imageDataUrlCache = createKeyedShortIpcCache<{ dataUrl: string }>( + (path) => ipcRenderer.invoke(IPC.appGetImageDataUrl, { path }), + 30_000, +); + +const projectIconCache = createKeyedShortIpcCache( + (rootPath) => ipcRenderer.invoke(IPC.projectResolveIcon, { rootPath }), + 30_000, +); + +const diffChangesCache = createKeyedShortIpcCache( + (key) => ipcRenderer.invoke(IPC.diffGetChanges, parseIpcCacheArgs(key, {} as GetDiffChangesArgs)), + 2_000, +); + +const gitBranchesCache = createKeyedShortIpcCache( + (key) => ipcRenderer.invoke(IPC.gitListBranches, parseIpcCacheArgs(key, {} as GitListBranchesArgs)), + 2_000, +); + +function clearGitReadCaches(): void { + diffChangesCache.clear(); + gitBranchesCache.clear(); + lanesListCache.clear(); + lanesListSnapshotsCache.clear(); + sessionDeltaCache.clear(); +} + +function clearProjectScopedReadCaches(): void { + clearGitReadCaches(); + projectConfigSnapshotCache.clear(); + agentChatSummaryCache.clear(); + computerUseOwnerSnapshotCache.clear(); + imageDataUrlCache.clear(); + projectIconCache.clear(); +} + +function clearIosSimulatorStatusCaches(): void { + iosSimulatorStatusCache.clear(); + iosSimulatorDevicesCache.clear(); +} + +function getAiStatusCacheKey(args?: { refreshOpenCodeInventory?: boolean }): string { + return serializeIpcCacheArgs({ + refreshOpenCodeInventory: args?.refreshOpenCodeInventory === true, + }); +} + +async function clearAround(clear: () => void, action: () => Promise): Promise { + clear(); + try { + return await action(); + } finally { + clear(); + } +} + +function createIpcEventFanout( + channel: string, + beforeDispatch?: (payload: T) => void, +): (cb: (payload: T) => void) => () => void { + const callbacks = new Set<(payload: T) => void>(); + let subscribed = false; + const listener = (_event: Electron.IpcRendererEvent, payload: T) => { + beforeDispatch?.(payload); + for (const cb of [...callbacks]) { + cb(payload); + } + }; + + return (cb: (payload: T) => void) => { + callbacks.add(cb); + if (!subscribed) { + ipcRenderer.on(channel, listener); + subscribed = true; + } + return () => { + callbacks.delete(cb); + if (callbacks.size === 0 && subscribed) { + ipcRenderer.removeListener(channel, listener); + subscribed = false; + } + }; + }; +} + +const agentChatEventFanout = createIpcEventFanout(IPC.agentChatEvent); +const computerUseEventFanout = createIpcEventFanout( + IPC.computerUseEvent, + () => computerUseOwnerSnapshotCache.clear(), +); +const iosSimulatorEventFanout = createIpcEventFanout( + IPC.iosSimulatorEvent, + () => clearIosSimulatorStatusCaches(), +); +const appControlEventFanout = createIpcEventFanout( + IPC.appControlEvent, + () => appControlStatusCache.clear(), +); +const ptyDataEventFanout = createIpcEventFanout(IPC.ptyData); +const ptyExitEventFanout = createIpcEventFanout(IPC.ptyExit); + contextBridge.exposeInMainWorld("ade", { app: { ping: async (): Promise<"pong"> => ipcRenderer.invoke(IPC.appPing), @@ -664,7 +953,10 @@ contextBridge.exposeInMainWorld("ade", { const listener = ( _event: Electron.IpcRendererEvent, payload: ProjectInfo | null, - ) => cb(payload); + ) => { + clearProjectScopedReadCaches(); + cb(payload); + }; ipcRenderer.on(IPC.appProjectChanged, listener); return () => ipcRenderer.removeListener(IPC.appProjectChanged, listener); }, @@ -679,7 +971,7 @@ contextBridge.exposeInMainWorld("ade", { readClipboardImage: async (): Promise<{ data: string; filename: string; mimeType: string } | null> => ipcRenderer.invoke(IPC.appReadClipboardImage), getImageDataUrl: async (path: string): Promise<{ dataUrl: string }> => - ipcRenderer.invoke(IPC.appGetImageDataUrl, { path }), + imageDataUrlCache.get(path), writeClipboardImage: async (path: string): Promise => ipcRenderer.invoke(IPC.appWriteClipboardImage, { path }), openPathInEditor: async (args: { @@ -692,7 +984,7 @@ contextBridge.exposeInMainWorld("ade", { }, project: { openRepo: async (): Promise => - ipcRenderer.invoke(IPC.projectOpenRepo), + clearAround(() => clearProjectScopedReadCaches(), () => ipcRenderer.invoke(IPC.projectOpenRepo)), chooseDirectory: async ( args: { title?: string; defaultPath?: string } = {}, ): Promise => @@ -704,11 +996,17 @@ contextBridge.exposeInMainWorld("ade", { getDetail: async (rootPath: string): Promise => ipcRenderer.invoke(IPC.projectGetDetail, { rootPath }), resolveIcon: async (rootPath: string): Promise => - ipcRenderer.invoke(IPC.projectResolveIcon, { rootPath }), + projectIconCache.get(rootPath), chooseIcon: async (rootPath: string): Promise => - ipcRenderer.invoke(IPC.projectChooseIcon, { rootPath }), + clearAround(() => { + imageDataUrlCache.clear(); + projectIconCache.clear(rootPath); + }, () => ipcRenderer.invoke(IPC.projectChooseIcon, { rootPath })), removeIcon: async (rootPath: string): Promise => - ipcRenderer.invoke(IPC.projectRemoveIcon, { rootPath }), + clearAround(() => { + imageDataUrlCache.clear(); + projectIconCache.clear(rootPath); + }, () => ipcRenderer.invoke(IPC.projectRemoveIcon, { rootPath })), getDroppedPath: (file: File): string => { try { return webUtils.getPathForFile(file); @@ -721,13 +1019,13 @@ contextBridge.exposeInMainWorld("ade", { clearLocalData: async ( args: ClearLocalAdeDataArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.projectClearLocalData, args), + clearAround(() => clearProjectScopedReadCaches(), () => ipcRenderer.invoke(IPC.projectClearLocalData, args)), listRecent: async (): Promise => ipcRenderer.invoke(IPC.projectListRecent), closeCurrent: async (): Promise => - ipcRenderer.invoke(IPC.projectCloseCurrent), + clearAround(() => clearProjectScopedReadCaches(), () => ipcRenderer.invoke(IPC.projectCloseCurrent)), switchToPath: async (rootPath: string): Promise => - ipcRenderer.invoke(IPC.projectSwitchToPath, { rootPath }), + clearAround(() => clearProjectScopedReadCaches(), () => ipcRenderer.invoke(IPC.projectSwitchToPath, { rootPath })), forgetRecent: async (rootPath: string): Promise => ipcRenderer.invoke(IPC.projectForgetRecent, { rootPath }), reorderRecent: async ( @@ -766,14 +1064,20 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.keybindingsSet, { overrides }), }, ai: { - getStatus: async (args?: { force?: boolean; refreshOpenCodeInventory?: boolean }): Promise => - ipcRenderer.invoke(IPC.aiGetStatus, args), + getStatus: async (args?: { force?: boolean; refreshOpenCodeInventory?: boolean }): Promise => { + const cacheKey = getAiStatusCacheKey(args); + if (args?.force === true) { + aiStatusCache.clear(); + return ipcRenderer.invoke(IPC.aiGetStatus, args); + } + return aiStatusCache.get(cacheKey); + }, getOpenCodeRuntimeDiagnostics: async (): Promise => ipcRenderer.invoke(IPC.aiGetOpenCodeRuntimeDiagnostics), storeApiKey: async (provider: string, key: string): Promise => - ipcRenderer.invoke(IPC.aiStoreApiKey, { provider, key }), + clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiStoreApiKey, { provider, key })), deleteApiKey: async (provider: string): Promise => - ipcRenderer.invoke(IPC.aiDeleteApiKey, { provider }), + clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiDeleteApiKey, { provider })), listApiKeys: async (): Promise => ipcRenderer.invoke(IPC.aiListApiKeys), verifyApiKey: async ( @@ -781,11 +1085,11 @@ contextBridge.exposeInMainWorld("ade", { ): Promise => ipcRenderer.invoke(IPC.aiVerifyApiKey, { provider }), updateConfig: async (config: Partial): Promise => - ipcRenderer.invoke(IPC.aiUpdateConfig, config), + clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiUpdateConfig, config)), }, sync: { - getStatus: async (): Promise => - ipcRenderer.invoke(IPC.syncGetStatus), + getStatus: async (args?: SyncGetStatusArgs): Promise => + ipcRenderer.invoke(IPC.syncGetStatus, args), refreshDiscovery: async (): Promise => ipcRenderer.invoke(IPC.syncRefreshDiscovery), listDevices: async (): Promise => @@ -1418,45 +1722,89 @@ contextBridge.exposeInMainWorld("ade", { }, lanes: { list: async (args: ListLanesArgs = {}): Promise => - ipcRenderer.invoke(IPC.lanesList, args), + lanesListCache.get(serializeIpcCacheArgs(args)), listSnapshots: async ( args: ListLanesArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.lanesListSnapshots, args), - create: async (args: CreateLaneArgs): Promise => - ipcRenderer.invoke(IPC.lanesCreate, args), - createChild: async (args: CreateChildLaneArgs): Promise => - ipcRenderer.invoke(IPC.lanesCreateChild, args), + lanesListSnapshotsCache.get(serializeIpcCacheArgs(args)), + create: async (args: CreateLaneArgs): Promise => { + clearGitReadCaches(); + const lane = await ipcRenderer.invoke(IPC.lanesCreate, args); + clearGitReadCaches(); + return lane; + }, + createChild: async (args: CreateChildLaneArgs): Promise => { + clearGitReadCaches(); + const lane = await ipcRenderer.invoke(IPC.lanesCreateChild, args); + clearGitReadCaches(); + return lane; + }, createFromUnstaged: async ( args: CreateLaneFromUnstagedArgs, - ): Promise => - ipcRenderer.invoke(IPC.lanesCreateFromUnstaged, args), - importBranch: async (args: ImportBranchLaneArgs): Promise => - ipcRenderer.invoke(IPC.lanesImportBranch, args), + ): Promise => { + clearGitReadCaches(); + const lane = await ipcRenderer.invoke(IPC.lanesCreateFromUnstaged, args); + clearGitReadCaches(); + return lane; + }, + importBranch: async (args: ImportBranchLaneArgs): Promise => { + clearGitReadCaches(); + const lane = await ipcRenderer.invoke(IPC.lanesImportBranch, args); + clearGitReadCaches(); + return lane; + }, previewBranchSwitch: async ( args: LaneBranchSwitchArgs, ): Promise => ipcRenderer.invoke(IPC.lanesPreviewBranchSwitch, args), switchBranch: async ( args: LaneBranchSwitchArgs, - ): Promise => - ipcRenderer.invoke(IPC.lanesSwitchBranch, args), - attach: async (args: AttachLaneArgs): Promise => - ipcRenderer.invoke(IPC.lanesAttach, args), + ): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.lanesSwitchBranch, args); + clearGitReadCaches(); + return result; + }, + attach: async (args: AttachLaneArgs): Promise => { + clearGitReadCaches(); + const lane = await ipcRenderer.invoke(IPC.lanesAttach, args); + clearGitReadCaches(); + return lane; + }, listUnregisteredWorktrees: async (): Promise => ipcRenderer.invoke(IPC.lanesListUnregisteredWorktrees), - adoptAttached: async (args: AdoptAttachedLaneArgs): Promise => - ipcRenderer.invoke(IPC.lanesAdoptAttached, args), - rename: async (args: RenameLaneArgs): Promise => - ipcRenderer.invoke(IPC.lanesRename, args), - reparent: async (args: ReparentLaneArgs): Promise => - ipcRenderer.invoke(IPC.lanesReparent, args), - updateAppearance: async (args: UpdateLaneAppearanceArgs): Promise => - ipcRenderer.invoke(IPC.lanesUpdateAppearance, args), - archive: async (args: ArchiveLaneArgs): Promise => - ipcRenderer.invoke(IPC.lanesArchive, args), - delete: async (args: DeleteLaneArgs): Promise => - ipcRenderer.invoke(IPC.lanesDelete, args), + adoptAttached: async (args: AdoptAttachedLaneArgs): Promise => { + clearGitReadCaches(); + const lane = await ipcRenderer.invoke(IPC.lanesAdoptAttached, args); + clearGitReadCaches(); + return lane; + }, + rename: async (args: RenameLaneArgs): Promise => { + clearGitReadCaches(); + await ipcRenderer.invoke(IPC.lanesRename, args); + clearGitReadCaches(); + }, + reparent: async (args: ReparentLaneArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.lanesReparent, args); + clearGitReadCaches(); + return result; + }, + updateAppearance: async (args: UpdateLaneAppearanceArgs): Promise => { + clearGitReadCaches(); + await ipcRenderer.invoke(IPC.lanesUpdateAppearance, args); + clearGitReadCaches(); + }, + archive: async (args: ArchiveLaneArgs): Promise => { + clearGitReadCaches(); + await ipcRenderer.invoke(IPC.lanesArchive, args); + clearGitReadCaches(); + }, + delete: async (args: DeleteLaneArgs): Promise => { + clearGitReadCaches(); + await ipcRenderer.invoke(IPC.lanesDelete, args); + clearGitReadCaches(); + }, cancelDelete: async (args: { laneId: string }): Promise<{ cancelled: boolean; reason?: string }> => ipcRenderer.invoke(IPC.lanesDeleteCancel, args), getDeleteRisk: async (args: { laneId: string }): Promise => @@ -1674,7 +2022,7 @@ contextBridge.exposeInMainWorld("ade", { readTranscriptTail: async (args: ReadTranscriptTailArgs): Promise => ipcRenderer.invoke(IPC.sessionsReadTranscriptTail, args), getDelta: async (sessionId: string): Promise => - ipcRenderer.invoke(IPC.sessionsGetDelta, { sessionId }), + sessionDeltaCache.get(sessionId), onChanged: (cb: (ev: TerminalSessionChangedEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -1691,10 +2039,15 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.agentChatList, args), getSummary: async ( args: AgentChatGetSummaryArgs, - ): Promise => - ipcRenderer.invoke(IPC.agentChatGetSummary, args), - create: async (args: AgentChatCreateArgs): Promise => - ipcRenderer.invoke(IPC.agentChatCreate, args), + ): Promise => { + const sessionId = typeof args?.sessionId === "string" ? args.sessionId.trim() : ""; + if (!sessionId) return ipcRenderer.invoke(IPC.agentChatGetSummary, args); + return agentChatSummaryCache.get(sessionId); + }, + create: async (args: AgentChatCreateArgs): Promise => { + agentChatSummaryCache.clear(); + return ipcRenderer.invoke(IPC.agentChatCreate, args); + }, suggestLaneName: async (args: AgentChatSuggestLaneNameArgs): Promise => ipcRenderer.invoke(IPC.agentChatSuggestLaneName, args), parallelLaunchState: { @@ -1707,52 +2060,94 @@ contextBridge.exposeInMainWorld("ade", { args: AgentChatHandoffArgs, ): Promise => ipcRenderer.invoke(IPC.agentChatHandoff, args), - send: async (args: AgentChatSendArgs): Promise => - ipcRenderer.invoke(IPC.agentChatSend, args), - steer: async (args: AgentChatSteerArgs): Promise => - ipcRenderer.invoke(IPC.agentChatSteer, args), - cancelSteer: async (args: AgentChatCancelSteerArgs): Promise => - ipcRenderer.invoke(IPC.agentChatCancelSteer, args), - editSteer: async (args: AgentChatEditSteerArgs): Promise => - ipcRenderer.invoke(IPC.agentChatEditSteer, args), - dispatchSteer: async (args: AgentChatDispatchSteerArgs): Promise => - ipcRenderer.invoke(IPC.agentChatDispatchSteer, args), - cancelDispatchedSteer: async (args: AgentChatCancelDispatchedSteerArgs): Promise => - ipcRenderer.invoke(IPC.agentChatCancelDispatchedSteer, args), - interrupt: async (args: AgentChatInterruptArgs): Promise => - ipcRenderer.invoke(IPC.agentChatInterrupt, args), - resume: async (args: AgentChatResumeArgs): Promise => - ipcRenderer.invoke(IPC.agentChatResume, args), - approve: async (args: AgentChatApproveArgs): Promise => - ipcRenderer.invoke(IPC.agentChatApprove, args), - respondToInput: async (args: AgentChatRespondToInputArgs): Promise => - ipcRenderer.invoke(IPC.agentChatRespondToInput, args), + send: async (args: AgentChatSendArgs): Promise => { + agentChatSummaryCache.clear(); + await ipcRenderer.invoke(IPC.agentChatSend, args); + agentChatSummaryCache.clear(); + }, + steer: async (args: AgentChatSteerArgs): Promise => { + agentChatSummaryCache.clear(); + await ipcRenderer.invoke(IPC.agentChatSteer, args); + agentChatSummaryCache.clear(); + }, + cancelSteer: async (args: AgentChatCancelSteerArgs): Promise => { + agentChatSummaryCache.clear(); + await ipcRenderer.invoke(IPC.agentChatCancelSteer, args); + agentChatSummaryCache.clear(); + }, + editSteer: async (args: AgentChatEditSteerArgs): Promise => { + agentChatSummaryCache.clear(); + await ipcRenderer.invoke(IPC.agentChatEditSteer, args); + agentChatSummaryCache.clear(); + }, + dispatchSteer: async (args: AgentChatDispatchSteerArgs): Promise => { + agentChatSummaryCache.clear(); + const result = await ipcRenderer.invoke(IPC.agentChatDispatchSteer, args); + agentChatSummaryCache.clear(); + return result; + }, + cancelDispatchedSteer: async (args: AgentChatCancelDispatchedSteerArgs): Promise => { + agentChatSummaryCache.clear(); + const result = await ipcRenderer.invoke(IPC.agentChatCancelDispatchedSteer, args); + agentChatSummaryCache.clear(); + return result; + }, + interrupt: async (args: AgentChatInterruptArgs): Promise => { + agentChatSummaryCache.clear(); + await ipcRenderer.invoke(IPC.agentChatInterrupt, args); + agentChatSummaryCache.clear(); + }, + resume: async (args: AgentChatResumeArgs): Promise => { + agentChatSummaryCache.clear(); + const session = await ipcRenderer.invoke(IPC.agentChatResume, args); + agentChatSummaryCache.clear(); + return session; + }, + approve: async (args: AgentChatApproveArgs): Promise => { + agentChatSummaryCache.clear(); + await ipcRenderer.invoke(IPC.agentChatApprove, args); + agentChatSummaryCache.clear(); + }, + respondToInput: async (args: AgentChatRespondToInputArgs): Promise => { + agentChatSummaryCache.clear(); + await ipcRenderer.invoke(IPC.agentChatRespondToInput, args); + agentChatSummaryCache.clear(); + }, models: async (args: AgentChatModelsArgs): Promise => ipcRenderer.invoke(IPC.agentChatModels, args), - dispose: async (args: AgentChatDisposeArgs): Promise => - ipcRenderer.invoke(IPC.agentChatDispose, args), - archive: async (args: AgentChatArchiveArgs): Promise => - ipcRenderer.invoke(IPC.agentChatArchive, args), - unarchive: async (args: AgentChatArchiveArgs): Promise => - ipcRenderer.invoke(IPC.agentChatUnarchive, args), - delete: async (args: AgentChatDeleteArgs): Promise => - ipcRenderer.invoke(IPC.agentChatDelete, args), + dispose: async (args: AgentChatDisposeArgs): Promise => { + agentChatSummaryCache.clear(); + await ipcRenderer.invoke(IPC.agentChatDispose, args); + agentChatSummaryCache.clear(); + }, + archive: async (args: AgentChatArchiveArgs): Promise => { + agentChatSummaryCache.clear(); + await ipcRenderer.invoke(IPC.agentChatArchive, args); + agentChatSummaryCache.clear(); + }, + unarchive: async (args: AgentChatArchiveArgs): Promise => { + agentChatSummaryCache.clear(); + await ipcRenderer.invoke(IPC.agentChatUnarchive, args); + agentChatSummaryCache.clear(); + }, + delete: async (args: AgentChatDeleteArgs): Promise => { + agentChatSummaryCache.clear(); + await ipcRenderer.invoke(IPC.agentChatDelete, args); + agentChatSummaryCache.clear(); + }, updateSession: async ( args: AgentChatUpdateSessionArgs, - ): Promise => - ipcRenderer.invoke(IPC.agentChatUpdateSession, args), + ): Promise => { + agentChatSummaryCache.clear(); + const session = await ipcRenderer.invoke(IPC.agentChatUpdateSession, args); + agentChatSummaryCache.clear(); + return session; + }, warmupModel: async (args: { sessionId: string; modelId: string; }): Promise => ipcRenderer.invoke(IPC.agentChatWarmupModel, args), - onEvent: (cb: (ev: AgentChatEventEnvelope) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: AgentChatEventEnvelope, - ) => cb(payload); - ipcRenderer.on(IPC.agentChatEvent, listener); - return () => ipcRenderer.removeListener(IPC.agentChatEvent, listener); - }, + onEvent: agentChatEventFanout, slashCommands: async ( args: AgentChatSlashCommandsArgs, ): Promise => @@ -1792,41 +2187,58 @@ contextBridge.exposeInMainWorld("ade", { getOwnerSnapshot: async ( args: ComputerUseOwnerSnapshotArgs, ): Promise => - ipcRenderer.invoke(IPC.computerUseGetOwnerSnapshot, args), + computerUseOwnerSnapshotCache.get(serializeIpcCacheArgs(args)), routeArtifact: async ( args: ComputerUseArtifactRouteArgs, ): Promise => - ipcRenderer.invoke(IPC.computerUseRouteArtifact, args), + clearAround( + () => computerUseOwnerSnapshotCache.clear(), + () => ipcRenderer.invoke(IPC.computerUseRouteArtifact, args), + ), updateArtifactReview: async ( args: ComputerUseArtifactReviewArgs, ): Promise => - ipcRenderer.invoke(IPC.computerUseUpdateArtifactReview, args), + clearAround( + () => computerUseOwnerSnapshotCache.clear(), + () => ipcRenderer.invoke(IPC.computerUseUpdateArtifactReview, args), + ), readArtifactPreview: async (args: { uri: string; }): Promise => ipcRenderer.invoke(IPC.computerUseReadArtifactPreview, args), - onEvent: (cb: (ev: ComputerUseEventPayload) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: ComputerUseEventPayload, - ) => cb(payload); - ipcRenderer.on(IPC.computerUseEvent, listener); - return () => ipcRenderer.removeListener(IPC.computerUseEvent, listener); - }, + onEvent: computerUseEventFanout, }, iosSimulator: { getStatus: async (): Promise => - ipcRenderer.invoke(IPC.iosSimulatorGetStatus), + iosSimulatorStatusCache.get(), listDevices: async (): Promise => - ipcRenderer.invoke(IPC.iosSimulatorListDevices), + iosSimulatorDevicesCache.get(), listLaunchTargets: async (args: IosSimulatorListLaunchTargetsArgs = {}): Promise => ipcRenderer.invoke(IPC.iosSimulatorListLaunchTargets, args), - launch: async (args: IosSimulatorLaunchArgs = {}): Promise => - ipcRenderer.invoke(IPC.iosSimulatorLaunch, args), - attachToChatSession: async (args: { chatSessionId: string | null; callerChatSessionId?: string | null }): Promise => - ipcRenderer.invoke(IPC.iosSimulatorAttachToChatSession, args), - shutdown: async (args: IosSimulatorShutdownArgs = {}): Promise => - ipcRenderer.invoke(IPC.iosSimulatorShutdown, args), + launch: async (args: IosSimulatorLaunchArgs = {}): Promise => { + clearIosSimulatorStatusCaches(); + try { + return await ipcRenderer.invoke(IPC.iosSimulatorLaunch, args); + } finally { + clearIosSimulatorStatusCaches(); + } + }, + attachToChatSession: async (args: { chatSessionId: string | null; callerChatSessionId?: string | null }): Promise => { + clearIosSimulatorStatusCaches(); + try { + return await ipcRenderer.invoke(IPC.iosSimulatorAttachToChatSession, args); + } finally { + clearIosSimulatorStatusCaches(); + } + }, + shutdown: async (args: IosSimulatorShutdownArgs = {}): Promise => { + clearIosSimulatorStatusCaches(); + try { + return await ipcRenderer.invoke(IPC.iosSimulatorShutdown, args); + } finally { + clearIosSimulatorStatusCaches(); + } + }, screenshot: async (args: { deviceUdid?: string | null } = {}): Promise => ipcRenderer.invoke(IPC.iosSimulatorScreenshot, args), getScreenSnapshot: async (args: IosScreenSnapshotArgs = {}): Promise => @@ -1843,10 +2255,22 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.iosSimulatorRenderPreview, args), openPreviewWorkspace: async (args: IosSimulatorOpenPreviewWorkspaceArgs = {}): Promise<{ ok: true; path: string }> => ipcRenderer.invoke(IPC.iosSimulatorOpenPreviewWorkspace, args), - startStream: async (args: IosSimulatorStartStreamArgs = {}): Promise => - ipcRenderer.invoke(IPC.iosSimulatorStartStream, args), - stopStream: async (): Promise => - ipcRenderer.invoke(IPC.iosSimulatorStopStream), + startStream: async (args: IosSimulatorStartStreamArgs = {}): Promise => { + clearIosSimulatorStatusCaches(); + try { + return await ipcRenderer.invoke(IPC.iosSimulatorStartStream, args); + } finally { + clearIosSimulatorStatusCaches(); + } + }, + stopStream: async (): Promise => { + clearIosSimulatorStatusCaches(); + try { + return await ipcRenderer.invoke(IPC.iosSimulatorStopStream); + } finally { + clearIosSimulatorStatusCaches(); + } + }, getStreamStatus: async (): Promise => ipcRenderer.invoke(IPC.iosSimulatorGetStreamStatus), getSimulatorWindowState: async (): Promise => @@ -1864,26 +2288,19 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.iosSimulatorSwipe, args), selectPoint: async (args: { deviceUdid?: string | null; projectRoot?: string | null; x: number; y: number }): Promise => ipcRenderer.invoke(IPC.iosSimulatorSelectPoint, args), - onEvent: (cb: (ev: IosSimulatorEventPayload) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: IosSimulatorEventPayload, - ) => cb(payload); - ipcRenderer.on(IPC.iosSimulatorEvent, listener); - return () => ipcRenderer.removeListener(IPC.iosSimulatorEvent, listener); - }, + onEvent: iosSimulatorEventFanout, }, appControl: { getStatus: async (): Promise => - ipcRenderer.invoke(IPC.appControlGetStatus), + appControlStatusCache.get(), launch: async (args: AppControlLaunchArgs = {}): Promise => - ipcRenderer.invoke(IPC.appControlLaunch, args), + clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlLaunch, args)), launchInTerminal: async (args: AppControlLaunchArgs = {}): Promise => - ipcRenderer.invoke(IPC.appControlLaunchInTerminal, args), + clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlLaunchInTerminal, args)), connect: async (args: AppControlConnectArgs): Promise => - ipcRenderer.invoke(IPC.appControlConnect, args), + clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlConnect, args)), stop: async (args: AppControlStopArgs = {}): Promise<{ ok: true; previousSession: AppControlSession | null }> => - ipcRenderer.invoke(IPC.appControlStop, args), + clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlStop, args)), screenshot: async (): Promise => ipcRenderer.invoke(IPC.appControlScreenshot), getSnapshot: async (args: AppControlSnapshotArgs = {}): Promise => @@ -1908,15 +2325,8 @@ contextBridge.exposeInMainWorld("ade", { listTargets: async (): Promise => ipcRenderer.invoke(IPC.appControlListTargets), attachToTarget: async (args: { targetId: string }): Promise => - ipcRenderer.invoke(IPC.appControlAttachToTarget, args), - onEvent: (cb: (ev: AppControlEventPayload) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: AppControlEventPayload, - ) => cb(payload); - ipcRenderer.on(IPC.appControlEvent, listener); - return () => ipcRenderer.removeListener(IPC.appControlEvent, listener); - }, + clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlAttachToTarget, args)), + onEvent: appControlEventFanout, }, terminal: { list: async (args: ChatTerminalListArgs = {}): Promise => @@ -1944,26 +2354,12 @@ contextBridge.exposeInMainWorld("ade", { ptyId: string; sessionId?: string; }): Promise => ipcRenderer.invoke(IPC.ptyDispose, arg), - onData: (cb: (ev: PtyDataEvent) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: PtyDataEvent, - ) => cb(payload); - ipcRenderer.on(IPC.ptyData, listener); - return () => ipcRenderer.removeListener(IPC.ptyData, listener); - }, - onExit: (cb: (ev: PtyExitEvent) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: PtyExitEvent, - ) => cb(payload); - ipcRenderer.on(IPC.ptyExit, listener); - return () => ipcRenderer.removeListener(IPC.ptyExit, listener); - }, + onData: ptyDataEventFanout, + onExit: ptyExitEventFanout, }, diff: { getChanges: async (args: GetDiffChangesArgs): Promise => - ipcRenderer.invoke(IPC.diffGetChanges, args), + diffChangesCache.get(serializeIpcCacheArgs(args)), getFile: async (args: GetFileDiffArgs): Promise => ipcRenderer.invoke(IPC.diffGetFile, args), }, @@ -2010,23 +2406,52 @@ contextBridge.exposeInMainWorld("ade", { }, }, git: { - stageFile: async (args: GitFileActionArgs): Promise => - ipcRenderer.invoke(IPC.gitStageFile, args), - stageAll: async (args: GitBatchFileActionArgs): Promise => - ipcRenderer.invoke(IPC.gitStageAll, args), - unstageFile: async (args: GitFileActionArgs): Promise => - ipcRenderer.invoke(IPC.gitUnstageFile, args), + stageFile: async (args: GitFileActionArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitStageFile, args); + clearGitReadCaches(); + return result; + }, + stageAll: async (args: GitBatchFileActionArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitStageAll, args); + clearGitReadCaches(); + return result; + }, + unstageFile: async (args: GitFileActionArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitUnstageFile, args); + clearGitReadCaches(); + return result; + }, unstageAll: async ( args: GitBatchFileActionArgs, - ): Promise => ipcRenderer.invoke(IPC.gitUnstageAll, args), - discardFile: async (args: GitFileActionArgs): Promise => - ipcRenderer.invoke(IPC.gitDiscardFile, args), + ): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitUnstageAll, args); + clearGitReadCaches(); + return result; + }, + discardFile: async (args: GitFileActionArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitDiscardFile, args); + clearGitReadCaches(); + return result; + }, restoreStagedFile: async ( args: GitFileActionArgs, - ): Promise => - ipcRenderer.invoke(IPC.gitRestoreStagedFile, args), - commit: async (args: GitCommitArgs): Promise => - ipcRenderer.invoke(IPC.gitCommit, args), + ): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitRestoreStagedFile, args); + clearGitReadCaches(); + return result; + }, + commit: async (args: GitCommitArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitCommit, args); + clearGitReadCaches(); + return result; + }, generateCommitMessage: async ( args: GitGenerateCommitMessageArgs, ): Promise => @@ -2040,36 +2465,80 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.gitListCommitFiles, args), getCommitMessage: async (args: GitGetCommitMessageArgs): Promise => ipcRenderer.invoke(IPC.gitGetCommitMessage, args), - revertCommit: async (args: GitRevertArgs): Promise => - ipcRenderer.invoke(IPC.gitRevertCommit, args), + revertCommit: async (args: GitRevertArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitRevertCommit, args); + clearGitReadCaches(); + return result; + }, cherryPickCommit: async ( args: GitCherryPickArgs, - ): Promise => - ipcRenderer.invoke(IPC.gitCherryPickCommit, args), - stashPush: async (args: GitStashPushArgs): Promise => - ipcRenderer.invoke(IPC.gitStashPush, args), + ): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitCherryPickCommit, args); + clearGitReadCaches(); + return result; + }, + stashPush: async (args: GitStashPushArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitStashPush, args); + clearGitReadCaches(); + return result; + }, stashList: async (args: { laneId: string }): Promise => ipcRenderer.invoke(IPC.gitStashList, args), - stashApply: async (args: GitStashRefArgs): Promise => - ipcRenderer.invoke(IPC.gitStashApply, args), - stashPop: async (args: GitStashRefArgs): Promise => - ipcRenderer.invoke(IPC.gitStashPop, args), - stashDrop: async (args: GitStashRefArgs): Promise => - ipcRenderer.invoke(IPC.gitStashDrop, args), - stashClear: async (args: { laneId: string }): Promise => - ipcRenderer.invoke(IPC.gitStashClear, args), - fetch: async (args: { laneId: string }): Promise => - ipcRenderer.invoke(IPC.gitFetch, args), - pull: async (args: { laneId: string }): Promise => - ipcRenderer.invoke(IPC.gitPull, args), + stashApply: async (args: GitStashRefArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitStashApply, args); + clearGitReadCaches(); + return result; + }, + stashPop: async (args: GitStashRefArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitStashPop, args); + clearGitReadCaches(); + return result; + }, + stashDrop: async (args: GitStashRefArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitStashDrop, args); + clearGitReadCaches(); + return result; + }, + stashClear: async (args: { laneId: string }): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitStashClear, args); + clearGitReadCaches(); + return result; + }, + fetch: async (args: { laneId: string }): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitFetch, args); + clearGitReadCaches(); + return result; + }, + pull: async (args: { laneId: string }): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitPull, args); + clearGitReadCaches(); + return result; + }, getSyncStatus: async (args: { laneId: string; }): Promise => ipcRenderer.invoke(IPC.gitGetSyncStatus, args), - sync: async (args: GitSyncArgs): Promise => - ipcRenderer.invoke(IPC.gitSync, args), - push: async (args: GitPushArgs): Promise => - ipcRenderer.invoke(IPC.gitPush, args), + sync: async (args: GitSyncArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitSync, args); + clearGitReadCaches(); + return result; + }, + push: async (args: GitPushArgs): Promise => { + clearGitReadCaches(); + const result = await ipcRenderer.invoke(IPC.gitPush, args); + clearGitReadCaches(); + return result; + }, getConflictState: async (laneId: string): Promise => ipcRenderer.invoke(IPC.gitGetConflictState, { laneId }), rebaseContinue: async (laneId: string): Promise => @@ -2083,7 +2552,7 @@ contextBridge.exposeInMainWorld("ade", { listBranches: async ( args: GitListBranchesArgs, ): Promise => - ipcRenderer.invoke(IPC.gitListBranches, args), + gitBranchesCache.get(serializeIpcCacheArgs(args)), checkoutBranch: async ( args: GitCheckoutBranchArgs, ): Promise => @@ -2185,13 +2654,15 @@ contextBridge.exposeInMainWorld("ade", { }, github: { getStatus: async (opts?: { forceRefresh?: boolean }): Promise => - ipcRenderer.invoke(IPC.githubGetStatus, opts ?? {}), + opts?.forceRefresh + ? clearAround(() => githubStatusCache.clear(), () => ipcRenderer.invoke(IPC.githubGetStatus, opts ?? {})) + : githubStatusCache.get(), setToken: async (token: string): Promise => - ipcRenderer.invoke(IPC.githubSetToken, { token }), + clearAround(() => githubStatusCache.clear(), () => ipcRenderer.invoke(IPC.githubSetToken, { token })), clearToken: async (): Promise => - ipcRenderer.invoke(IPC.githubClearToken), + clearAround(() => githubStatusCache.clear(), () => ipcRenderer.invoke(IPC.githubClearToken)), detectRepo: async (): Promise<{ owner: string; name: string } | null> => { - const status = await ipcRenderer.invoke(IPC.githubGetStatus) as GitHubStatus; + const status = await githubStatusCache.get(); return status.repo; }, listRepoLabels: async (args: { owner: string; name: string }): Promise> => @@ -2199,8 +2670,10 @@ contextBridge.exposeInMainWorld("ade", { listRepoCollaborators: async (args: { owner: string; name: string }): Promise> => ipcRenderer.invoke(IPC.githubListRepoCollaborators, args), onStatusChanged: (cb: (status: GitHubStatus) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: GitHubStatus) => + const listener = (_event: Electron.IpcRendererEvent, payload: GitHubStatus) => { + githubStatusCache.clear(); cb(payload); + }; ipcRenderer.on(IPC.githubStatusChanged, listener); return () => ipcRenderer.removeListener(IPC.githubStatusChanged, listener); }, @@ -2585,21 +3058,36 @@ contextBridge.exposeInMainWorld("ade", { }, projectConfig: { get: async (): Promise => - ipcRenderer.invoke(IPC.projectConfigGet), + projectConfigSnapshotCache.get(), validate: async ( candidate: ProjectConfigCandidate, ): Promise => ipcRenderer.invoke(IPC.projectConfigValidate, { candidate }), save: async ( candidate: ProjectConfigCandidate, - ): Promise => - ipcRenderer.invoke(IPC.projectConfigSave, { candidate }), + ): Promise => { + projectConfigSnapshotCache.clear(); + try { + const snapshot = await ipcRenderer.invoke(IPC.projectConfigSave, { candidate }); + projectConfigSnapshotCache.clear(); + return snapshot; + } catch (error) { + projectConfigSnapshotCache.clear(); + throw error; + } + }, diffAgainstDisk: async (): Promise => ipcRenderer.invoke(IPC.projectConfigDiffAgainstDisk), confirmTrust: async ( arg: { sharedHash?: string } = {}, - ): Promise => - ipcRenderer.invoke(IPC.projectConfigConfirmTrust, arg), + ): Promise => { + projectConfigSnapshotCache.clear(); + try { + return await ipcRenderer.invoke(IPC.projectConfigConfirmTrust, arg); + } finally { + projectConfigSnapshotCache.clear(); + } + }, }, zoom: { getLevel: (): number => webFrame.getZoomLevel(), diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 1d6951e4d..8ad2f1dcc 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -397,7 +397,13 @@ export function AppShell({ children }: { children: React.ReactNode }) { laneRefreshTimer = window.setTimeout(() => { laneRefreshTimer = null; if (cancelled) return; - void refreshLanes({ includeStatus: true }); + const includeDecoratedLaneSnapshots = isLanesRoute; + void refreshLanes({ + includeStatus: includeDecoratedLaneSnapshots, + includeConflictStatus: includeDecoratedLaneSnapshots, + includeRebaseSuggestions: isLanesRoute, + includeAutoRebaseStatus: includeDecoratedLaneSnapshots, + }); }, 1_200); providerRefreshTimer = window.setTimeout(() => { providerRefreshTimer = null; @@ -441,6 +447,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { refreshProviderMode, refreshKeybindings, setShowWelcome, + isLanesRoute, ]); useEffect(() => { @@ -703,23 +710,31 @@ export function AppShell({ children }: { children: React.ReactNode }) { return; } setAiStatusLoaded(false); - const timer = window.setTimeout(() => { - void Promise.allSettled([ - window.ade.ai.getStatus(), - window.ade.github.getStatus(), - ]).then((results) => { + const aiTimer = window.setTimeout(() => { + void window.ade.ai.getStatus().then((status) => { + if (cancelled) return; + setAiStatus(status); + }).catch(() => { + if (cancelled) return; + setAiStatus(null); + }).finally(() => { if (cancelled) return; - const [aiResult, githubResult] = results; - setAiStatus(aiResult.status === "fulfilled" ? aiResult.value : null); setAiStatusLoaded(true); - setGithubStatus( - githubResult.status === "fulfilled" ? githubResult.value : null, - ); }); }, 1_000); + const githubTimer = window.setTimeout(() => { + void window.ade.github.getStatus().then((status) => { + if (cancelled) return; + setGithubStatus(status); + }).catch(() => { + if (cancelled) return; + setGithubStatus(null); + }); + }, 4_000); return () => { cancelled = true; - window.clearTimeout(timer); + window.clearTimeout(aiTimer); + window.clearTimeout(githubTimer); }; }, [project?.rootPath]); diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index 91443a312..4cbb025dd 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -163,7 +163,7 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) "absolute -right-1 -top-1 ade-status-dot", terminalAttention.indicator === "running-needs-attention" ? "ade-status-dot-warning" - : "ade-status-dot-active animate-spin", + : "ade-status-dot-active", )} /> ) : null} diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index bc7e043c6..942fe38d5 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -154,7 +154,7 @@ describe("TopBar", () => { render(); expect(await screen.findByText("1 phone connected")).toBeTruthy(); - expect(globalThis.window.ade.sync.getStatus).toHaveBeenCalled(); + expect(globalThis.window.ade.sync.getStatus).toHaveBeenCalledWith({ includeTransferReadiness: false }); }); it("opens the phone sync drawer from the host status control", async () => { diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index f037b0db7..5a6185372 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -359,7 +359,7 @@ export function TopBar() { useEffect(() => { let cancelled = false; const refreshSyncStatus = () => { - void window.ade.sync.getStatus().then((snapshot) => { + void window.ade.sync.getStatus({ includeTransferReadiness: false }).then((snapshot) => { if (!cancelled) setSyncSnapshot(snapshot); }).catch(() => { if (!cancelled) setSyncSnapshot(null); @@ -677,7 +677,7 @@ export function TopBar() { "ade-status-dot h-1.5 w-1.5 shrink-0", indicator === "running-needs-attention" ? "ade-status-dot-warning" - : "ade-status-dot-active animate-pulse" + : "ade-status-dot-active" )} /> ) : null} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index adc434032..e81e2e614 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { At, CaretDown, Check, Desktop, DeviceMobile, Image, Paperclip, PencilSimple, Square, X, PaperPlaneTilt, SquareSplitHorizontal, Plus, Trash, Lightning, ArrowBendDownRight } from "@phosphor-icons/react"; import { BorderBeam } from "border-beam"; @@ -1976,7 +1976,7 @@ export function AgentChatComposer({ setSelectedAppControlContextId(null); }, [appControlContextItems, selectedAppControlContextId]); - const composerBeamActive = layoutVariant !== "grid-tile" && (turnActive || !chatHasMessages); + const composerBeamActive = isActive && layoutVariant !== "grid-tile" && turnActive; const composerBeamVariant = turnActive ? "ocean" : "colorful"; const composerBeamDuration = turnActive ? 20 : 5; const composerBeamStrength = turnActive ? 0.26 : 0.44; @@ -2002,21 +2002,35 @@ export function AgentChatComposer({ return "Send"; } - return ( - <> + const composerFrameClassName = cn( + "m-3 mt-0 rounded-[var(--chat-radius-shell)]", + layoutVariant === "grid-tile" ? "m-0" : "", + ); + const composerFrameStyle = { overflow: "visible" as const }; + const renderComposerFrame = (children: ReactNode) => ( + composerBeamActive ? ( + {children} + + ) : ( +
+ {children} +
+ ) + ); + + return ( + <> + {renderComposerFrame( - + )} ); } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index f6219df6d..4068fefd1 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1260,6 +1260,11 @@ function writeChatCompanionUiState(key: string, state: ChatCompanionUiState): vo } } +function isLikelyMacRenderer(): boolean { + if (typeof navigator === "undefined") return false; + return /\bMac\b/i.test(navigator.platform) || /\bMac OS X\b/i.test(navigator.userAgent); +} + export function AgentChatPane({ laneId, laneLabel, @@ -1388,7 +1393,7 @@ export function AgentChatPane({ const [iosSimulatorOpen, setIosSimulatorOpen] = useState( () => readChatCompanionUiState(initialCompanionStateKey).iosSimulatorOpen, ); - const [iosSimulatorAvailable, setIosSimulatorAvailable] = useState(false); + const [iosSimulatorAvailable, setIosSimulatorAvailable] = useState(isLikelyMacRenderer); const [iosElementContextItems, setIosElementContextItems] = useState([]); const [appControlOpen, setAppControlOpen] = useState( () => readChatCompanionUiState(initialCompanionStateKey).appControlOpen, @@ -1534,6 +1539,7 @@ export function AgentChatPane({ useEffect(() => { const api = window.ade?.iosSimulator; if (!api?.getStatus) return; + if (!iosSimulatorOpen || !isTileActive) return; let cancelled = false; void api.getStatus() .then((status) => { @@ -1547,7 +1553,7 @@ export function AgentChatPane({ return () => { cancelled = true; }; - }, []); + }, [iosSimulatorOpen, isTileActive]); useEffect(() => { const api = window.ade?.appControl; @@ -2204,7 +2210,15 @@ export function AgentChatPane({ }); const refreshAvailableModels = useCallback(async () => { - const shouldRefreshOpenCodeInventory = sessionProvider === "opencode"; + const selectedModelProvider = modelId.trim() + ? resolveChatRuntimeProvider(getModelById(modelId)) + : null; + const shouldRefreshOpenCodeInventory = + sessionProvider === "opencode" + && ( + selectedSession?.provider === "opencode" + || selectedModelProvider === "opencode" + ); try { const status = await getAiStatusCached({ projectRoot, @@ -2281,7 +2295,7 @@ export function AgentChatPane({ setAvailableModelIds([]); return []; } - }, [projectRoot, sessionProvider]); + }, [modelId, projectRoot, selectedSession?.provider, sessionProvider]); const touchSession = useCallback((sessionId: string | null | undefined, touchedAt = new Date().toISOString()) => { if (!sessionId) return; @@ -2431,8 +2445,9 @@ export function AgentChatPane({ }, [selectedSessionId]); useEffect(() => { + if (!isTileActive) return; void refreshAvailableModels(); - }, [refreshAvailableModels, selectedSession?.provider]); + }, [isTileActive, refreshAvailableModels, selectedSession?.provider]); useEffect(() => { // Suspend the 5s model-list poll when this pane is mounted but hidden @@ -2676,7 +2691,7 @@ export function AgentChatPane({ ]); useEffect(() => { - if (!selectedSessionId || !selectedSessionModelId || turnActive) return; + if (!isTileActive || !selectedSessionId || !selectedSessionModelId || turnActive) return; const desc = getModelById(selectedSessionModelId); if (!desc?.isCliWrapped || desc.family !== "cursor") return; const warmupKey = `${selectedSessionId}:${selectedSessionModelId}:${selectedSession?.cursorModeSnapshot?.currentModeId ?? cursorModeId ?? "agent"}`; @@ -2692,6 +2707,7 @@ export function AgentChatPane({ selectedSession?.cursorModeSnapshot?.currentModeId, selectedSessionId, selectedSessionModelId, + isTileActive, turnActive, ]); @@ -2817,6 +2833,7 @@ export function AgentChatPane({ }, [handoffOpen, handoffModelId, handoffTargetDescriptor]); useEffect(() => { + if (!isTileActive) return; if (!selectedSessionId) return; if (!lockedSingleSessionMode) { // Re-read the selected transcript on every tab switch so the selected @@ -2834,9 +2851,13 @@ export function AgentChatPane({ void loadHistory(selectedSessionId, { force: true }); }, 120); return () => window.clearTimeout(handle); - }, [loadHistory, lockedSingleSessionMode, selectedSessionId]); + }, [isTileActive, loadHistory, lockedSingleSessionMode, selectedSessionId]); useEffect(() => { + if (!isTileActive) { + setComputerUseSnapshot(null); + return; + } if (!lockedSingleSessionMode) { void refreshComputerUseSnapshot(selectedSessionId); return; @@ -2845,7 +2866,7 @@ export function AgentChatPane({ void refreshComputerUseSnapshot(selectedSessionId); }, 180); return () => window.clearTimeout(handle); - }, [lockedSingleSessionMode, refreshComputerUseSnapshot, selectedSessionId]); + }, [isTileActive, lockedSingleSessionMode, refreshComputerUseSnapshot, selectedSessionId]); useEffect(() => { setAttachments([]); @@ -2861,17 +2882,17 @@ export function AgentChatPane({ // Fetch SDK slash commands when session changes useEffect(() => { - if (!selectedSessionId) { setSdkSlashCommands([]); return; } + if (!selectedSessionId || !isTileActive) { setSdkSlashCommands([]); return; } let cancelled = false; window.ade.agentChat.slashCommands({ sessionId: selectedSessionId }) .then((cmds) => { if (!cancelled) setSdkSlashCommands(cmds); }) .catch(() => { if (!cancelled) setSdkSlashCommands([]); }); return () => { cancelled = true; }; - }, [selectedSessionId]); + }, [isTileActive, selectedSessionId]); // Fetch git diff stats when the session changes or a turn completes useEffect(() => { - if (!selectedSessionId) { setSessionDelta(null); return; } + if (!selectedSessionId || !isTileActive) { setSessionDelta(null); return; } let cancelled = false; const fetchDelta = () => { window.ade.sessions.getDelta(selectedSessionId) @@ -2887,7 +2908,7 @@ export function AgentChatPane({ }; fetchDelta(); return () => { cancelled = true; }; - }, [selectedSessionId, turnActive]); + }, [isTileActive, selectedSessionId, turnActive]); const flushQueuedEvents = useCallback(() => { const queued = pendingEventQueueRef.current; @@ -3073,6 +3094,7 @@ export function AgentChatPane({ }, [lockSessionId, flushQueuedEvents, patchSessionSummary, scheduleQueuedEventFlush, scheduleSessionsRefresh, touchSession]); useEffect(() => { + if (!isTileActive) return undefined; const unsubscribe = window.ade.computerUse.onEvent((event) => { if (!selectedSessionId) return; if (event.owner?.kind === "chat_session" && event.owner.id === selectedSessionId) { @@ -3080,7 +3102,7 @@ export function AgentChatPane({ } }); return unsubscribe; - }, [refreshComputerUseSnapshot, selectedSessionId]); + }, [isTileActive, refreshComputerUseSnapshot, selectedSessionId]); useEffect(() => { if (!selectedSessionId) { @@ -4684,7 +4706,7 @@ export function AgentChatPane({ surfaceMode={surfaceMode} layoutVariant={layoutVariant} composerMaxHeightPx={composerMaxHeightPx} - isActive={layoutVariant === "grid-tile" ? isTileActive : false} + isActive={isTileActive} shouldAutofocus={layoutVariant === "grid-tile" ? shouldAutofocusComposer : false} sdkSlashCommands={sdkSlashCommands} modelId={modelId} diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx index c460fa528..a033aa25a 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx @@ -77,25 +77,14 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ const navigate = useNavigate(); const runtime = useLaneGitActionRuntimeState(laneId); const laneColor = useAppStore((s) => s.lanes.find((l) => l.id === laneId)?.color ?? null); + const laneName = useAppStore((s) => s.lanes.find((l) => l.id === laneId)?.name ?? null); - const [laneName, setLaneName] = useState(null); const [dirtyCount, setDirtyCount] = useState(0); const [diffStats, setDiffStats] = useState<{ adds: number; dels: number; files: number } | null>(null); const [commitOpen, setCommitOpen] = useState(false); const [commitMsg, setCommitMsg] = useState(""); const [linkedPr, setLinkedPr] = useState(null); - // Fetch lane display name - useEffect(() => { - let cancelled = false; - window.ade.lanes.list({}).then((lanes: Array<{ id: string; name: string }>) => { - if (cancelled) return; - const match = lanes.find((l) => l.id === laneId); - if (match) setLaneName(match.name); - }).catch(() => {}); - return () => { cancelled = true; }; - }, [laneId]); - // ----------------------------------------------------------------------- // Refresh git status + PR link // ----------------------------------------------------------------------- diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index 7320ddd38..474f29757 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -127,6 +127,10 @@ function GraphInner() { const lanes = useAppStore((s) => s.lanes); const lanesKey = React.useMemo(() => lanes.map((l) => l.id).join(","), [lanes]); const refreshLanes = useAppStore((s) => s.refreshLanes); + const refreshGraphLanes = React.useCallback( + () => refreshLanes({ includeStatus: false }), + [refreshLanes] + ); const [environmentMappings, setEnvironmentMappings] = React.useState([]); const [prs, setPrs] = React.useState([]); const [syncByLaneId, setSyncByLaneId] = React.useState>({}); @@ -370,7 +374,10 @@ function GraphInner() { () => sessionState[viewMode] ?? createSnapshot(viewMode), [sessionState, viewMode] ); - const filters = coalesceGraphFilters(activeSnapshot.filters); + const filters = React.useMemo( + () => coalesceGraphFilters(activeSnapshot.filters), + [activeSnapshot.filters] + ); const environmentByLaneId = React.useMemo(() => { const compiled = environmentMappings @@ -651,8 +658,8 @@ function GraphInner() { const refreshGraphPrSurface = React.useCallback(async () => { await refreshPrs(); - await Promise.allSettled([refreshRiskBatch(), refreshLanes()]); - }, [refreshLanes, refreshPrs, refreshRiskBatch]); + await Promise.allSettled([refreshRiskBatch(), refreshGraphLanes()]); + }, [refreshGraphLanes, refreshPrs, refreshRiskBatch]); const refreshIntegrationProposals = React.useCallback(async () => { try { @@ -767,7 +774,7 @@ function GraphInner() { setSelectedLaneIds([]); setShowFiltersPanel(false); - void refreshLanes() + void refreshGraphLanes() .catch((err) => { console.warn("[Graph] refreshLanes failed:", err); reportGraphIssue("The graph could not load the latest lanes.", err); @@ -800,7 +807,7 @@ function GraphInner() { if (syncTimer != null) window.clearTimeout(syncTimer); if (autoRebaseTimer != null) window.clearTimeout(autoRebaseTimer); }; - }, [project?.rootPath, refreshAutoRebaseStatuses, refreshLaneSyncStatuses, refreshLanes, refreshRiskBatch, reportGraphIssue, scheduleRefreshActivity]); + }, [project?.rootPath, refreshAutoRebaseStatuses, refreshGraphLanes, refreshLaneSyncStatuses, refreshRiskBatch, reportGraphIssue, scheduleRefreshActivity]); React.useEffect(() => { let cancelled = false; @@ -1116,7 +1123,7 @@ function GraphInner() { } const interval = window.setInterval(() => { if (document.visibilityState !== "visible") return; - void refreshLanes().catch((err) => console.warn("[Graph] periodic refreshLanes failed:", err)); + void refreshGraphLanes().catch((err) => console.warn("[Graph] periodic refreshLanes failed:", err)); scheduleRefreshActivity(320, { includeOperations: true }); }, 60_000); const syncInterval = window.setInterval(() => { @@ -1162,7 +1169,7 @@ function GraphInner() { prRefreshTimerRef.current = null; } }; - }, [project?.rootPath, refreshLaneSyncStatuses, refreshLanes, refreshRiskBatch, refreshAutoRebaseStatuses, reportGraphIssue, scheduleRefreshActivity, scheduleRefreshPrs]); + }, [project?.rootPath, refreshLaneSyncStatuses, refreshGraphLanes, refreshRiskBatch, refreshAutoRebaseStatuses, reportGraphIssue, scheduleRefreshActivity, scheduleRefreshPrs]); const baseGraph = React.useMemo(() => { if (!loadedGraphPreferences) { diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index d61a235b0..08071ea1d 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -306,6 +306,7 @@ export function LanesPage() { const branchSearchInputRef = useRef(null); const branchDropdownRef = useRef(null); const completedLaneDeleteRefreshesRef = useRef>(new Set()); + const activeLanePresenceSignatureRef = useRef(null); const [addLaneDropdownOpen, setAddLaneDropdownOpen] = useState(false); const addLaneDropdownRef = useRef(null); @@ -476,6 +477,11 @@ export function LanesPage() { return; } const laneIds = project?.rootPath ? [...visibleLaneIds] : []; + const signature = laneIds.join("\0"); + if (activeLanePresenceSignatureRef.current === signature) { + return; + } + activeLanePresenceSignatureRef.current = signature; void syncApi.setActiveLanePresence({ laneIds }).catch(() => {}); }, [project?.rootPath, visibleLaneIds]); @@ -485,6 +491,10 @@ export function LanesPage() { return; } return () => { + if (activeLanePresenceSignatureRef.current === "") { + return; + } + activeLanePresenceSignatureRef.current = ""; void syncApi.setActiveLanePresence({ laneIds: [] }).catch(() => {}); }; }, []); @@ -720,12 +730,19 @@ export function LanesPage() { useEffect(() => { let timer: ReturnType | null = null; + const refreshRuntimeOnly = () => + refreshLanes({ + includeStatus: true, + includeConflictStatus: false, + includeRebaseSuggestions: false, + includeAutoRebaseStatus: false, + }); const scheduleRefresh = () => { if (document.visibilityState !== "visible") return; if (timer) return; // already scheduled timer = setTimeout(() => { timer = null; - void refreshLanes().catch(() => {}); + void refreshRuntimeOnly().catch(() => {}); }, 300); }; const currentProjectRoot = project?.rootPath ?? null; @@ -741,7 +758,7 @@ export function LanesPage() { const intervalId = window.setInterval(() => { if (document.visibilityState !== "visible") return; if (!hasActiveLaneRuntimeRef.current) return; - void refreshLanes().catch(() => {}); + void refreshRuntimeOnly().catch(() => {}); }, 15_000); return () => { if (timer) clearTimeout(timer); @@ -2562,7 +2579,7 @@ export function LanesPage() { ) : ( )} - {/* Terminal attention spinner */} + {/* Terminal attention state */} {laneRuntime.bucket === "running" || laneRuntime.bucket === "awaiting-input" ? ( ) : laneRuntime.bucket === "ended" ? ( diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index d47cf567f..b6c6faca4 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -318,6 +318,7 @@ describe("TerminalView", () => { lineHeight: 1.25, scrollback: 10_000, }; + window.localStorage.removeItem("ade.terminalRenderer"); }); afterEach(() => { @@ -328,8 +329,6 @@ describe("TerminalView", () => { }); it("fits to the container and resizes the PTY when the fit result is valid", async () => { - // `await import("@xterm/addon-webgl")` may not settle under Vi's fake timers on CI shards, - // so use real timers + waitFor to let the microtask chain drain reliably. vi.useRealTimers(); try { render(); @@ -337,7 +336,7 @@ describe("TerminalView", () => { await waitFor( () => { const runtime = getTerminalRuntimeSnapshot("session-valid"); - expect(runtime?.renderer).toBe("webgl"); + expect(runtime?.renderer).toBe("dom"); expect(runtime?.health.fitRecoveries).toBe(0); expect((window as any).ade.pty.resize).toHaveBeenCalledWith({ ptyId: "pty-valid", @@ -352,6 +351,24 @@ describe("TerminalView", () => { } }); + it("uses the WebGL renderer when explicitly opted in", async () => { + vi.useRealTimers(); + try { + window.localStorage.setItem("ade.terminalRenderer", "webgl"); + render(); + + await waitFor( + () => { + const runtime = getTerminalRuntimeSnapshot("session-webgl"); + expect(runtime?.renderer).toBe("webgl"); + }, + { timeout: 10_000 }, + ); + } finally { + vi.useFakeTimers(); + } + }); + it("rejects implausible fit results, restores the last good size, and skips PTY resize", async () => { render(); await flushAllTimers(); @@ -424,6 +441,7 @@ describe("TerminalView", () => { // `await import("@xterm/addon-webgl")` may not settle under Vi's fake timers on CI shards. vi.useRealTimers(); try { + window.localStorage.setItem("ade.terminalRenderer", "webgl"); mockState.shouldThrowWebglAddon = true; const previousFallbacks = getTerminalRuntimeSnapshot("session-dom")?.health.rendererFallbacks ?? 0; diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index 89b05d9f8..86f7617c6 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -95,6 +95,7 @@ const MIN_HOST_WIDTH_PX = 120; const MIN_HOST_HEIGHT_PX = 48; const INVALID_FIT_RETRY_MS = 90; const RENDERER_RESET_COOLDOWN_MS = 250; +const TERMINAL_RENDERER_STORAGE_KEY = "ade.terminalRenderer"; const runtimeCache = new Map(); let parkedRoot: HTMLDivElement | null = null; @@ -119,6 +120,14 @@ function isDarkTheme(theme: ThemeId): boolean { return theme === "dark"; } +function terminalWebglRendererEnabled(): boolean { + try { + return window.localStorage.getItem(TERMINAL_RENDERER_STORAGE_KEY) === "webgl"; + } catch { + return false; + } +} + function cloneHealth(health: TerminalHealthCounters): TerminalHealthCounters { return { fitFailures: health.fitFailures, @@ -706,6 +715,11 @@ async function initRendererChain(runtime: CachedRuntime) { if (runtime.rendererInitStarted || runtime.disposed) return; runtime.rendererInitStarted = true; + if (!terminalWebglRendererEnabled()) { + await setRenderer(runtime, "dom"); + return; + } + const webgl = await setRenderer(runtime, "webgl"); if (webgl) return; incrementHealth(runtime, "rendererFallbacks"); @@ -782,7 +796,7 @@ function createRuntime(args: { const term = new Terminal({ allowProposedApi: true, convertEol: true, - cursorBlink: true, + cursorBlink: false, cursorInactiveStyle: "none", documentOverride: document, scrollback: args.preferences.scrollback, diff --git a/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx b/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx index 18dd85bcb..f9bd16bb6 100644 --- a/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx +++ b/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx @@ -84,6 +84,7 @@ export function PaneTilingLayout({ /* Leaf panel refs for individual pane minimization */ const leafPanelRefs = useRef>({}); const leafCompactedRef = useRef>({}); + const lastReadyLogSignatureRef = useRef(null); const replaceTreeImmediately = useCallback((next: PaneSplit, resetLayout = false) => { if (saveTimerRef.current) { @@ -351,15 +352,21 @@ export function PaneTilingLayout({ /* ---- Recursive renderer ---- */ + const liveLeafCount = useMemo(() => collectLeafIds(liveTree).length, [liveTree]); + const paneCount = useMemo(() => Object.keys(panes).length, [panes]); + useEffect(() => { + const signature = `${layoutId}:${loaded ? 1 : 0}:${treeLoaded ? 1 : 0}:${liveLeafCount}:${paneCount}`; + if (lastReadyLogSignatureRef.current === signature) return; + lastReadyLogSignatureRef.current = signature; logRendererDebugEvent("renderer.pane_layout.ready", { layoutId, loaded, treeLoaded, - liveLeafCount: collectLeafIds(liveTree).length, - paneCount: Object.keys(panes).length, + liveLeafCount, + paneCount, }); - }, [layoutId, loaded, treeLoaded, liveTree, panes]); + }, [layoutId, loaded, treeLoaded, liveLeafCount, paneCount]); const renderNode = ( node: PaneLeaf | PaneSplit, diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index 49a037a16..8e56e1a9e 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -2558,6 +2558,17 @@ button:active, [role="button"]:active { rgba(113, 113, 122, 0.02) 8px); } +/* Keep route identity without burning idle GPU on decorative ambient motion. */ +.ade-tab-bg { + will-change: auto; +} + +.ade-tab-bg, +.ade-tab-bg::before, +.ade-tab-bg::after { + animation: none; +} + /* ═══════════════════════════════════════════════════════════ Utility Classes ═══════════════════════════════════════════════════════════ */ @@ -3273,7 +3284,7 @@ button:active, [role="button"]:active { /* Active terminal green glow border */ .ade-terminal-active-glow { - animation: ade-terminal-pulse 3s ease-in-out infinite; + animation: none; } /* Tab icon breathing glow for active missions */ diff --git a/apps/desktop/src/renderer/lib/terminalAttention.test.ts b/apps/desktop/src/renderer/lib/terminalAttention.test.ts index 70fb30c6b..2b9f7ae09 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.test.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.test.ts @@ -56,12 +56,12 @@ describe("terminalAttention", () => { }); describe("sessionStatusDot", () => { - it("returns a spinning emerald dot for a running active session", () => { + it("returns a solid emerald dot for a running active session", () => { const dot = sessionStatusDot({ status: "running", lastOutputPreview: "building project...", }); - expect(dot.spinning).toBe(true); + expect(dot.spinning).toBe(false); expect(dot.cls).toContain("emerald"); expect(dot.label).toBe("Running"); }); diff --git a/apps/desktop/src/renderer/lib/terminalAttention.ts b/apps/desktop/src/renderer/lib/terminalAttention.ts index 6a7800cf2..c6a500cea 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.ts @@ -138,7 +138,7 @@ export function sessionStatusDot(session: { }): SessionStatusDot { const indicator = sessionIndicatorState(session); if (indicator === "running-active") { - return { cls: "rounded-full border-2 border-emerald-400 border-t-transparent bg-transparent", spinning: true, label: "Running" }; + return { cls: "rounded-full bg-emerald-400", spinning: false, label: "Running" }; } if (indicator === "running-needs-attention") { let label: string; diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 5ebfafd2d..087c5ddab 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -278,6 +278,9 @@ describe("appStore", () => { expect(window.ade.lanes.listSnapshots).toHaveBeenCalledWith({ includeArchived: false, includeStatus: true, + includeConflictStatus: true, + includeRebaseSuggestions: true, + includeAutoRebaseStatus: true, }); expect(useAppStore.getState().laneSnapshots).toEqual(snapshots); expect(useAppStore.getState().lanes).toEqual([snapshots[0].lane]); @@ -297,6 +300,48 @@ describe("appStore", () => { expect(useAppStore.getState().lanes).toEqual(lanes); }); + it("refreshLanes can skip conflict status for cheaper warmup snapshots", async () => { + (window.ade.lanes.listSnapshots as any).mockResolvedValueOnce([]); + + await useAppStore.getState().refreshLanes({ includeStatus: true, includeConflictStatus: false }); + + expect(window.ade.lanes.listSnapshots).toHaveBeenCalledWith({ + includeArchived: false, + includeStatus: true, + includeConflictStatus: false, + includeRebaseSuggestions: true, + includeAutoRebaseStatus: true, + }); + }); + + it("refreshLanes can skip rebase suggestions for cheaper warmup snapshots", async () => { + (window.ade.lanes.listSnapshots as any).mockResolvedValueOnce([]); + + await useAppStore.getState().refreshLanes({ includeStatus: true, includeRebaseSuggestions: false }); + + expect(window.ade.lanes.listSnapshots).toHaveBeenCalledWith({ + includeArchived: false, + includeStatus: true, + includeConflictStatus: true, + includeRebaseSuggestions: false, + includeAutoRebaseStatus: true, + }); + }); + + it("refreshLanes can skip auto-rebase status for cheaper warmup snapshots", async () => { + (window.ade.lanes.listSnapshots as any).mockResolvedValueOnce([]); + + await useAppStore.getState().refreshLanes({ includeStatus: true, includeAutoRebaseStatus: false }); + + expect(window.ade.lanes.listSnapshots).toHaveBeenCalledWith({ + includeArchived: false, + includeStatus: true, + includeConflictStatus: true, + includeRebaseSuggestions: true, + includeAutoRebaseStatus: false, + }); + }); + it("refreshLanes preserves compatible lane snapshots during lightweight refresh", async () => { useAppStore.setState({ laneSnapshots: [ diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index e5ba216c9..7333dde96 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -592,7 +592,12 @@ type AppState = { openNewTab: () => void; cancelNewTab: () => void; refreshProject: () => Promise; - refreshLanes: (options?: { includeStatus?: boolean }) => Promise; + refreshLanes: (options?: { + includeStatus?: boolean; + includeConflictStatus?: boolean; + includeRebaseSuggestions?: boolean; + includeAutoRebaseStatus?: boolean; + }) => Promise; openRepo: () => Promise; switchProjectToPath: (rootPath: string) => Promise; closeProject: () => Promise; @@ -602,6 +607,9 @@ export type LaneInspectorTab = "terminals" | "context" | "stack" | "merge"; type LaneRefreshRequest = { includeStatus: boolean; + includeConflictStatus: boolean; + includeRebaseSuggestions: boolean; + includeAutoRebaseStatus: boolean; }; let warmupTimer: number | null = null; @@ -612,13 +620,27 @@ let laneRefreshInFlight: Promise | null = null; let activeLaneRefreshRequest: LaneRefreshRequest | null = null; let pendingLaneRefreshRequest: LaneRefreshRequest | null = null; -function normalizeLaneRefreshRequest(options?: { includeStatus?: boolean }): LaneRefreshRequest { - return { includeStatus: options?.includeStatus ?? true }; +function normalizeLaneRefreshRequest(options?: { + includeStatus?: boolean; + includeConflictStatus?: boolean; + includeRebaseSuggestions?: boolean; + includeAutoRebaseStatus?: boolean; +}): LaneRefreshRequest { + const includeStatus = options?.includeStatus ?? true; + return { + includeStatus, + includeConflictStatus: includeStatus && (options?.includeConflictStatus ?? true), + includeRebaseSuggestions: includeStatus && (options?.includeRebaseSuggestions ?? true), + includeAutoRebaseStatus: includeStatus && (options?.includeAutoRebaseStatus ?? true), + }; } function mergeLaneRefreshRequests(current: LaneRefreshRequest, next: LaneRefreshRequest): LaneRefreshRequest { return { includeStatus: current.includeStatus || next.includeStatus, + includeConflictStatus: current.includeConflictStatus || next.includeConflictStatus, + includeRebaseSuggestions: current.includeRebaseSuggestions || next.includeRebaseSuggestions, + includeAutoRebaseStatus: current.includeAutoRebaseStatus || next.includeAutoRebaseStatus, }; } @@ -629,7 +651,12 @@ function scheduleProjectHydration(get: () => AppState) { const delay = Math.max(1_200, 1_800); warmupTimer = window.setTimeout(() => { warmupTimer = null; - void get().refreshLanes({ includeStatus: true }).catch((err) => { + void get().refreshLanes({ + includeStatus: true, + includeConflictStatus: false, + includeRebaseSuggestions: false, + includeAutoRebaseStatus: false, + }).catch((err) => { console.debug("Scheduled lane refresh failed:", err); }); void get().refreshProviderMode(); @@ -891,9 +918,12 @@ export const useAppStore = create((set, get) => ({ const token = ++laneRefreshVersion; const laneSnapshots = currentRequest.includeStatus ? await window.ade.lanes.listSnapshots({ - includeArchived: false, - includeStatus: true, - }) + includeArchived: false, + includeStatus: true, + includeConflictStatus: currentRequest.includeConflictStatus, + includeRebaseSuggestions: currentRequest.includeRebaseSuggestions, + includeAutoRebaseStatus: currentRequest.includeAutoRebaseStatus, + }) : null; const lanes = laneSnapshots != null ? laneSnapshots.map((snapshot) => snapshot.lane) @@ -947,7 +977,12 @@ export const useAppStore = create((set, get) => ({ if (laneRefreshInFlight) { const activeRequest = activeLaneRefreshRequest; - const activeSatisfies = activeRequest != null && (activeRequest.includeStatus || !request.includeStatus); + const activeSatisfies = + activeRequest != null + && (activeRequest.includeStatus || !request.includeStatus) + && (activeRequest.includeConflictStatus || !request.includeConflictStatus) + && (activeRequest.includeRebaseSuggestions || !request.includeRebaseSuggestions) + && (activeRequest.includeAutoRebaseStatus || !request.includeAutoRebaseStatus); if (!activeSatisfies) { pendingLaneRefreshRequest = pendingLaneRefreshRequest ? mergeLaneRefreshRequests(pendingLaneRefreshRequest, request) diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index 53ca03ef4..0d253adab 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -124,6 +124,9 @@ export type LaneIcon = "star" | "flag" | "bolt" | "shield" | "tag" | null; export type ListLanesArgs = { includeArchived?: boolean; includeStatus?: boolean; + includeConflictStatus?: boolean; + includeRebaseSuggestions?: boolean; + includeAutoRebaseStatus?: boolean; }; export type CreateLaneArgs = { diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 0596ffaa2..37d1d2454 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -136,6 +136,16 @@ export type SyncTransferReadiness = { survivableState: string[]; }; +export type SyncGetStatusArgs = { + /** + * Transfer readiness scans active missions, chats, terminal sessions, and + * managed run processes. Top-level chrome can skip it when it only needs the + * connection label. + */ + includeTransferReadiness?: boolean; + forceTransferReadiness?: boolean; +}; + export type SyncDeviceRuntimeState = SyncDeviceRecord & { isLocal: boolean; // Legacy internal/wire flag. User-facing copy should say "host". From 0d5461c03a5970c5e5a3825dc9e5fe70e67859dc Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 30 Apr 2026 05:19:43 -0400 Subject: [PATCH 2/3] =?UTF-8?q?ship:=20iter=201=20=E2=80=94=20fix=20CI=20f?= =?UTF-8?q?ailures=20+=20address=20CodeRabbit=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI fixes (test-ade-cli, test-desktop shards 1 + 3) - AgentChatComposer: revert BorderBeam wrapper conditional that was unmounting/remounting the composer subtree on turnActive flip, swallowing onChange events. Always render the wrapper, drive with active prop. (test-desktop (1) submit-recovery, 2 tests) - memoryService.evaluateWriteGate: re-add `tier IN (1,2)` to dedupe scan. The optimization had let candidate (tier-3) episodes dedupe against each other, which broke procedural promotion when 3 distinct episodes shared signals. Tier-2 promoted writes still fall under the filter, so the policy change for `resolveAgentMemoryWritePolicy` remains intact. (test-desktop (3) proceduralLearningService, 3 tests) - adeRpcServer.test: update one assertion to match the intentional new promoted/tier-2 contract; load-bearing scopeOwner/runId-from-env assertions unchanged. (test-ade-cli, 1 test) Review fixes (13/14 CodeRabbit comments — preserves optimization intent) - aiIntegrationService: shallowCliAuth keys off !shouldProbeCliModels so refreshOpenCodeInventory runs a full auth pass. - registerIpc.timePhase: takes a thunk + Promise.resolve().then.catch so synchronous throws degrade to []/null fallbacks. - autoRebaseService: hasAuthoritativeLaneSet guard prevents wiping persisted statuses for lanes outside a caller-supplied subset. - rebaseSuggestionService: normalize primary lane refs before origin/ lookup; scope cache + in-flight promise to default request shape only (force/lanes/refreshRemoteTracking bypass shared cache). - knowledgeCaptureService: low-signal courtesy regex now also requires !hasDurablePrFeedbackSignals && wordCount<10, preserving "Thanks but always X..." style actionable guidance. - projectIconResolver: drop negative-icon caching (no invalidation signal exists for added icon files). - ptyService.onData: early return on entry.disposed so late chunks cannot mutate post-teardown state. - syncService: force=true bypasses cache only, not in-flight dedupe. - preload.createKeyedShortIpcCache: bounded LRU-ish cap (256 entries, touch-on-access) for high-cardinality keys. - preload IPC fanout: per-callback try/catch so one throwing subscriber does not starve later subscribers. - preload agentChatEventFanout: beforeDispatch clears agentChatSummaryCache so background events invalidate the 1s cache. - WorkspaceGraphPage.refreshGraphLanes: includeStatus=true (skip conflict/rebase phases instead) so dirty/behind chips stay current without reverting to a full snapshot. Dismissed (1/14) - authDetector.ts skip-path "skipped probes shouldn't claim authenticated": false positive. The `authenticated:true, verified:false` shape is an intentional presence sentinel pinned by the colocated test (authDetector.test.ts:144-150); combined with the CLI-probe full-auth fix above, the only consumer treats it as a fast-path hint, not a verified credential. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ade-cli/src/adeRpcServer.test.ts | 6 +-- .../main/services/ai/aiIntegrationService.ts | 2 +- .../src/main/services/ipc/registerIpc.ts | 26 ++++++++---- .../main/services/lanes/autoRebaseService.ts | 7 +++- .../services/lanes/rebaseSuggestionService.ts | 15 ++++--- .../memory/knowledgeCaptureService.ts | 16 +++++++- .../src/main/services/memory/memoryService.ts | 1 + .../services/projects/projectIconResolver.ts | 6 ++- .../src/main/services/pty/ptyService.ts | 6 +++ .../src/main/services/sync/syncService.ts | 5 ++- apps/desktop/src/preload/preload.ts | 41 +++++++++++++++++-- .../components/chat/AgentChatComposer.tsx | 26 ++++-------- .../components/graph/WorkspaceGraphPage.tsx | 7 +++- 13 files changed, 120 insertions(+), 44 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 88c9f4910..c274ec804 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2657,9 +2657,9 @@ describe("adeRpcServer", () => { expect.objectContaining({ scope: "mission", scopeOwnerId: "run-from-env", - status: "candidate", - tier: 3, - confidence: 0.6, + status: "promoted", + tier: 2, + confidence: 1, }) ); expect(fixture.runtime.memoryService.addSharedFact).toHaveBeenCalledWith( diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 3ebf77926..ee639ffd5 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -1336,7 +1336,7 @@ export function createAiIntegrationService(args: { try { const auth = await timePhase("detect_auth", () => detectAuth({ force: options?.force, - shallowCliAuth: options?.force !== true, + shallowCliAuth: !shouldProbeCliModels, })); const available = await timePhase("resolve_available_models", () => getResolvedAvailableModels(auth, { discoverCliModels: shouldProbeCliModels }) diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index e9dae0fed..4a989394a 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -862,10 +862,10 @@ async function buildLaneListSnapshots( ): Promise { const startedAt = Date.now(); const phases: Array<{ phase: string; durationMs: number }> = []; - const timePhase = async (phase: string, work: Promise): Promise => { + const timePhase = async (phase: string, work: () => Promise | T): Promise => { const phaseStartedAt = Date.now(); try { - return await work; + return await work(); } finally { const durationMs = Date.now() - phaseStartedAt; phases.push({ phase, durationMs }); @@ -883,17 +883,29 @@ async function buildLaneListSnapshots( }; const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ - timePhase("sessions", enrichSessionsForLaneList(args)), + timePhase("sessions", () => enrichSessionsForLaneList(args)), options.includeRebaseSuggestions === false ? Promise.resolve([]) - : timePhase("rebase_suggestions", Promise.resolve(args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []).catch(() => [])), + : timePhase("rebase_suggestions", () => + Promise.resolve() + .then(() => args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []) + .catch(() => [])), options.includeAutoRebaseStatus === false ? Promise.resolve([]) - : timePhase("auto_rebase_statuses", Promise.resolve(args.autoRebaseService?.listStatuses({ lanes }) ?? []).catch(() => [])), - timePhase("state_snapshots", Promise.resolve(args.laneService.listStateSnapshots()).catch(() => [])), + : timePhase("auto_rebase_statuses", () => + Promise.resolve() + .then(() => args.autoRebaseService?.listStatuses({ lanes }) ?? []) + .catch(() => [])), + timePhase("state_snapshots", () => + Promise.resolve() + .then(() => args.laneService.listStateSnapshots()) + .catch(() => [])), options.includeConflictStatus === false ? Promise.resolve(null) - : timePhase("conflict_assessment", args.conflictService?.getBatchAssessment({ lanes }).catch(() => null) ?? Promise.resolve(null)), + : timePhase("conflict_assessment", () => + Promise.resolve() + .then(() => args.conflictService?.getBatchAssessment({ lanes }) ?? null) + .catch(() => null)), ]); const durationMs = Date.now() - startedAt; if (durationMs >= 120) { diff --git a/apps/desktop/src/main/services/lanes/autoRebaseService.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.ts index d7fd47653..a934040a4 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.ts @@ -265,6 +265,11 @@ export function createAutoRebaseService(args: { void maybeSweepRoots("listStatuses"); const lanes = options?.lanes ?? await laneService.list({ includeArchived: false }); if (disposed) return []; + // When a caller-supplied lane subset is provided, laneById is no longer + // authoritative for the full active-lane set, so we cannot infer "parent + // was deleted" from "parent missing from this slice" — skip the + // missing-parent prune in that case. + const hasAuthoritativeLaneSet = !options?.lanes; const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); const nowMs = Date.now(); @@ -285,7 +290,7 @@ export function createAutoRebaseService(args: { } else if (!options?.includeAll && lane.status.behind <= 0 && status.source !== "manual") { clearStatus(lane.id); continue; - } else if (status.parentLaneId && !laneById.has(status.parentLaneId)) { + } else if (hasAuthoritativeLaneSet && status.parentLaneId && !laneById.has(status.parentLaneId)) { clearStatus(lane.id); continue; } diff --git a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts index e3350888b..0449232d6 100644 --- a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts +++ b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts @@ -155,7 +155,7 @@ export function createRebaseSuggestionService(args: { parent: LaneSummary, options: { refreshRemoteTracking?: boolean } = {}, ): Promise => { - const parentBranch = parent.branchRef.trim(); + const parentBranch = branchNameFromLaneRef(parent.branchRef).trim(); if (!parentBranch) return null; if (options.refreshRemoteTracking) { await fetchRemoteTrackingBranch({ @@ -356,22 +356,27 @@ export function createRebaseSuggestionService(args: { }; const listSuggestions = async (options: ListSuggestionsOptions = {}): Promise => { + // Only share the global cache and in-flight promise for default (no + // request-specific options) requests. Caller-supplied lane subsets and + // refreshRemoteTracking each compute different results, so they must not + // read or populate the shared default-result cache. + const useSharedCache = !options.force && !options.lanes && options.refreshRemoteTracking !== true; const nowMs = Date.now(); - if (!options.force && cachedSuggestions && nowMs - cachedSuggestions.atMs < SUGGESTION_CACHE_TTL_MS) { + if (useSharedCache && cachedSuggestions && nowMs - cachedSuggestions.atMs < SUGGESTION_CACHE_TTL_MS) { return cachedSuggestions.suggestions; } - if (!options.force && suggestionsInFlight) { + if (useSharedCache && suggestionsInFlight) { return suggestionsInFlight; } const generation = suggestionsCacheGeneration; const work = computeSuggestions(options); - if (!options.force) { + if (useSharedCache) { suggestionsInFlight = work; } try { const suggestions = await work; - if (generation === suggestionsCacheGeneration) { + if (useSharedCache && generation === suggestionsCacheGeneration) { cachedSuggestions = { atMs: Date.now(), suggestions }; } return suggestions; diff --git a/apps/desktop/src/main/services/memory/knowledgeCaptureService.ts b/apps/desktop/src/main/services/memory/knowledgeCaptureService.ts index f8e7f2672..4cc016e7d 100644 --- a/apps/desktop/src/main/services/memory/knowledgeCaptureService.ts +++ b/apps/desktop/src/main/services/memory/knowledgeCaptureService.ts @@ -203,11 +203,23 @@ function hasDurablePrFeedbackSignals(value: string): boolean { function isLowSignalPrFeedbackContent(value: string): boolean { const trimmed = cleanText(value); if (!trimmed.length) return true; - if (GENERIC_PR_FEEDBACK_PATTERNS.some((pattern) => pattern.test(trimmed))) return true; - if (DERIVABLE_PR_FEEDBACK_PATTERNS.some((pattern) => pattern.test(trimmed))) return true; const withoutUrls = trimmed.replace(/https?:\/\/\S+/gi, " ").replace(/\s+/g, " ").trim(); const wordCount = withoutUrls.split(/\s+/).filter(Boolean).length; + + // Treat generic-prefix matches (e.g. "Thanks", "Acknowledged") as low-signal + // only when there's no durable guidance attached and the comment is short. + // Otherwise actionable PR feedback that begins with courtesy text would be + // dropped before it ever reaches the durable-signal check below. + if ( + GENERIC_PR_FEEDBACK_PATTERNS.some((pattern) => pattern.test(trimmed)) + && !hasDurablePrFeedbackSignals(trimmed) + && wordCount < 10 + ) { + return true; + } + if (DERIVABLE_PR_FEEDBACK_PATTERNS.some((pattern) => pattern.test(trimmed))) return true; + if (/https?:\/\//i.test(trimmed) && wordCount <= 8) return true; return !hasDurablePrFeedbackSignals(trimmed) && wordCount < 7; diff --git a/apps/desktop/src/main/services/memory/memoryService.ts b/apps/desktop/src/main/services/memory/memoryService.ts index cb659447b..27754a411 100644 --- a/apps/desktop/src/main/services/memory/memoryService.ts +++ b/apps/desktop/src/main/services/memory/memoryService.ts @@ -585,6 +585,7 @@ export function createMemoryService(db: AdeDb, serviceOpts: CreateMemoryServiceO AND scope = ? AND COALESCE(scope_owner_id, '') = ? AND status != 'archived' + AND tier IN (1, 2) ORDER BY updated_at DESC LIMIT 120 `, diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.ts b/apps/desktop/src/main/services/projects/projectIconResolver.ts index 48f368434..f7f830dfa 100644 --- a/apps/desktop/src/main/services/projects/projectIconResolver.ts +++ b/apps/desktop/src/main/services/projects/projectIconResolver.ts @@ -528,7 +528,11 @@ export function resolveProjectIcon( const iconPath = resolveProjectIconPath(root, options); if (!iconPath) { - return cacheValue({ dataUrl: null, sourcePath: null, mimeType: null }, null); + // Don't cache negative lookups: there is no real source path to key off, + // so adding an icon (e.g. src/app/icon.png) under an existing workspace + // tree wouldn't change the cached mtimes and the UI would keep showing + // "no icon" until the cache TTL expires. + return { dataUrl: null, sourcePath: null, mimeType: null }; } const mimeType = mimeTypeForIconPath(iconPath); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 68cd284ee..1e2b6fcc7 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -1699,6 +1699,12 @@ export function createPtyService({ let titleBufferFull = false; pty.onData((data) => { + // Late chunks can arrive after closeEntry()/dispose() has flushed the + // final buffer and emitted ptyExit. Bail out so post-teardown data + // can't re-arm pendingDataTimer, mutate previews/runtime state, or + // emit ptyData after ptyExit while transcript summarization is in + // flight. + if (entry.disposed) return; writeTranscript(entry, data); updatePreviewThrottled(entry, data); enqueuePtyData(entry, { ptyId, sessionId, data }); diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index fbceb6c5d..87451de1c 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -787,7 +787,10 @@ export function createSyncService(args: SyncServiceArgs) { if (!options?.force && transferReadinessCache && transferReadinessCache.expiresAtMs > now) { return transferReadinessCache.value; } - if (!options?.force && transferReadinessInFlight) return transferReadinessInFlight; + // `force` should skip the cached value but still share the in-flight + // promise — otherwise overlapping forced callers each spawn their own + // computeTransferReadiness() run. + if (transferReadinessInFlight) return transferReadinessInFlight; transferReadinessInFlight = computeTransferReadiness() .then((value) => { transferReadinessCache = { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2cd6d496b..22197b047 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -692,19 +692,43 @@ function createShortIpcCache(loader: () => Promise, ttlMs: number): ShortI }; } +// Soft cap to keep keyed caches from growing unboundedly across long desktop +// sessions when callers use high-cardinality keys (image paths, session ids, +// diff arg blobs, etc.). Map iteration order is insertion order, so deleting +// the first key approximates LRU when combined with the touch-on-access below. +const KEYED_IPC_CACHE_MAX_ENTRIES = 256; + function createKeyedShortIpcCache( loader: (key: string) => Promise, ttlMs: number, + options: { maxEntries?: number } = {}, ): { clear: (key?: string) => void; get: (key: string, opts?: { force?: boolean }) => Promise; } { + const maxEntries = options.maxEntries ?? KEYED_IPC_CACHE_MAX_ENTRIES; const caches = new Map>(); + const touch = (key: string, cache: ShortIpcCache) => { + // Move to most-recently-used position by re-inserting. + caches.delete(key); + caches.set(key, cache); + }; + const evictIfNeeded = () => { + while (caches.size > maxEntries) { + const oldestKey = caches.keys().next().value; + if (oldestKey === undefined) break; + caches.delete(oldestKey); + } + }; const getCache = (key: string): ShortIpcCache => { const existing = caches.get(key); - if (existing) return existing; + if (existing) { + touch(key, existing); + return existing; + } const cache = createShortIpcCache(() => loader(key), ttlMs); caches.set(key, cache); + evictIfNeeded(); return cache; }; @@ -907,7 +931,13 @@ function createIpcEventFanout( const listener = (_event: Electron.IpcRendererEvent, payload: T) => { beforeDispatch?.(payload); for (const cb of [...callbacks]) { - cb(payload); + // Isolate subscribers: a single throwing listener must not abort + // delivery to the rest of the fanout. + try { + cb(payload); + } catch (error) { + console.error(`preload IPC fanout listener failed for ${channel}`, error); + } } }; @@ -927,7 +957,12 @@ function createIpcEventFanout( }; } -const agentChatEventFanout = createIpcEventFanout(IPC.agentChatEvent); +const agentChatEventFanout = createIpcEventFanout( + IPC.agentChatEvent, + // Streamed/background agent activity changes session state too — invalidate + // the 1s summary cache before listeners can read a stale value. + () => agentChatSummaryCache.clear(), +); const computerUseEventFanout = createIpcEventFanout( IPC.computerUseEvent, () => computerUseOwnerSnapshotCache.clear(), diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index e81e2e614..129f01993 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { At, CaretDown, Check, Desktop, DeviceMobile, Image, Paperclip, PencilSimple, Square, X, PaperPlaneTilt, SquareSplitHorizontal, Plus, Trash, Lightning, ArrowBendDownRight } from "@phosphor-icons/react"; import { BorderBeam } from "border-beam"; @@ -2006,31 +2006,19 @@ export function AgentChatComposer({ "m-3 mt-0 rounded-[var(--chat-radius-shell)]", layoutVariant === "grid-tile" ? "m-0" : "", ); - const composerFrameStyle = { overflow: "visible" as const }; - const renderComposerFrame = (children: ReactNode) => ( - composerBeamActive ? ( + + return ( + <> - {children} - - ) : ( -
- {children} -
- ) - ); - - return ( - <> - {renderComposerFrame( - )} + ); } diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index 474f29757..1bed35249 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -128,7 +128,12 @@ function GraphInner() { const lanesKey = React.useMemo(() => lanes.map((l) => l.id).join(","), [lanes]); const refreshLanes = useAppStore((s) => s.refreshLanes); const refreshGraphLanes = React.useCallback( - () => refreshLanes({ includeStatus: false }), + // The graph still renders lane.status data (dirty/behind chips, selected + // lane summary, hover tooltip), so refreshing without status leaves those + // badges stale after commits, rebases, pushes, or external repo changes. + // Skip the heavier conflict/rebase-suggestion phases instead — those are + // refreshed on their own cadences elsewhere on this page. + () => refreshLanes({ includeStatus: true, includeConflictStatus: false, includeRebaseSuggestions: false, includeAutoRebaseStatus: false }), [refreshLanes] ); const [environmentMappings, setEnvironmentMappings] = React.useState([]); From 5a0ff218edb49416d461fae6d66ed0ebaefa202d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 30 Apr 2026 05:34:10 -0400 Subject: [PATCH 3/3] =?UTF-8?q?ship:=20iter=202=20=E2=80=94=20guard=20crea?= =?UTF-8?q?teShortIpcCache=20.finally=20against=20force-call=20races?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capy-ai caught a real concurrency bug introduced by the optimization pass: the generic createShortIpcCache helper unconditionally cleared its shared `promise` ref in .finally(), so when a `force: true` call overwrote `promise` mid-flight, the older request's settle would null out the new request. A subsequent non-force caller then saw `promise === null` and started a redundant IPC instead of reusing the in-flight forced load. Fix mirrors the pattern already used by the bespoke aiStatusCache 30 lines below: capture the request reference, only null `promise` if it still equals our request. Addresses capy-ai comment 3166962638. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop/src/preload/preload.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 22197b047..949dde5f3 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -678,16 +678,17 @@ function createShortIpcCache(loader: () => Promise, ttlMs: number): ShortI if (promise) return promise; } - promise = loader() + const req = loader() .then((next) => { value = next; expiresAt = Date.now() + ttlMs; return next; }) .finally(() => { - promise = null; + if (promise === req) promise = null; }); - return promise; + promise = req; + return req; }, }; }