From 3e7078aedab49894e096c3012609b6f29ea8765e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 04:13:52 -0400 Subject: [PATCH 01/80] fix(remote): harden beta runtime transport --- .../desktopBridgeServer.test.ts | 13 - .../builtInBrowser/desktopBridgeServer.ts | 36 +-- .../main/services/ios/iosSimulatorService.ts | 4 + .../src/main/services/ipc/ipcTimeouts.test.ts | 15 +- .../src/main/services/ipc/ipcTimeouts.ts | 54 ++++ .../lanes/runtimeDiagnosticsService.ts | 19 +- .../localRuntimeConnectionPool.ts | 55 ++-- .../src/main/services/probeLocalhostPort.ts | 10 +- .../main/services/processes/processService.ts | 10 +- .../remoteRuntime/remoteBootstrap.test.ts | 137 ++++++++-- .../services/remoteRuntime/remoteBootstrap.ts | 251 +++++++++++++++--- .../remoteConnectionPool.test.ts | 10 +- .../remoteRuntime/remoteConnectionPool.ts | 20 +- .../remoteRuntime/runtimeRpcClient.ts | 6 +- .../services/remoteRuntime/sshTransport.ts | 51 +++- scripts/package-channel.mjs | 14 + 16 files changed, 558 insertions(+), 147 deletions(-) diff --git a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.test.ts b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.test.ts index db734fd33..f7d2ecbcf 100644 --- a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.test.ts +++ b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.test.ts @@ -48,16 +48,6 @@ describe("startBuiltInBrowserDesktopBridgeServer", () => { it("creates private Unix socket directories and socket files", async () => { if (process.platform === "win32") return; - let currentUmask = 0o000; - const umaskCalls: number[] = []; - const umask = (mask?: number): number => { - const previous = currentUmask; - if (mask != null) { - umaskCalls.push(mask); - currentUmask = mask; - } - return previous; - }; const socketDir = path.join(tempDir, "sock"); fs.mkdirSync(socketDir, { mode: 0o755 }); fs.chmodSync(socketDir, 0o755); @@ -68,13 +58,10 @@ describe("startBuiltInBrowserDesktopBridgeServer", () => { socketPath, service: { getStatus: async () => ({ ok: true }) } as unknown as BuiltInBrowserService, logger: createLogger(), - umask, }); await waitForPath(socketPath); await waitForMode(socketPath, 0o600); - expect(umaskCalls).toEqual([0o177, 0o000]); - expect(currentUmask).toBe(0o000); expect(fs.statSync(socketDir).mode & 0o777).toBe(0o700); expect(fs.statSync(socketPath).mode & 0o777).toBe(0o600); } finally { diff --git a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts index 6b5782e87..96dbfb8b6 100644 --- a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts +++ b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import net from "node:net"; +import os from "node:os"; import path from "node:path"; import { JsonRpcError, @@ -33,7 +34,6 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { socketPath: string; service: BuiltInBrowserService; logger: Logger; - umask?: (mask?: number) => number; }): BuiltInBrowserDesktopBridgeServer { const { socketPath, service, logger } = args; const isNamedPipe = socketPath.startsWith("\\\\"); @@ -41,8 +41,11 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { if (!isNamedPipe) { const socketDir = path.dirname(socketPath); try { + const existed = fs.existsSync(socketDir); fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 }); - fs.chmodSync(socketDir, 0o700); + if (!isSystemTempDir(socketDir) || !existed) { + fs.chmodSync(socketDir, 0o700); + } } catch (error) { logger.warn("built_in_browser_bridge.sockdir_create_failed", { socketPath, @@ -92,27 +95,7 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { }); }); - let restoreSocketUmask: (() => void) | null = null; - if (!isNamedPipe && process.platform !== "win32") { - const setUmask = args.umask ?? process.umask.bind(process); - try { - const previousUmask = setUmask(0o177); - let restored = false; - restoreSocketUmask = () => { - if (restored) return; - restored = true; - setUmask(previousUmask); - }; - } catch (error) { - logger.warn("built_in_browser_bridge.sock_umask_failed", { - socketPath, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - server.on("error", (error) => { - restoreSocketUmask?.(); logger.error("built_in_browser_bridge.server_error", { socketPath, reason: error instanceof Error ? error.message : String(error), @@ -121,7 +104,6 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { try { server.listen(socketPath, () => { - restoreSocketUmask?.(); if (!isNamedPipe) { try { fs.chmodSync(socketPath, 0o600); @@ -141,7 +123,6 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { logger.info("built_in_browser_bridge.listening", { socketPath }); }); } catch (error) { - restoreSocketUmask?.(); throw error; } @@ -217,3 +198,10 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { }, }; } + +function isSystemTempDir(dirPath: string): boolean { + const normalized = path.resolve(dirPath); + return normalized === path.resolve(os.tmpdir()) + || normalized === "/tmp" + || normalized === "/private/tmp"; +} diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.ts index de5355398..767f292ba 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.ts @@ -1119,8 +1119,12 @@ async function waitForTcpPort(host: string, port: number, timeoutMs: number): Pr while (Date.now() < deadline) { const connected = await new Promise((resolve) => { const socket = net.createConnection({ host, port }); + let settled = false; const finish = (ok: boolean) => { + if (settled) return; + settled = true; socket.removeAllListeners(); + socket.on("error", () => {}); socket.destroy(); resolve(ok); }; diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts index 8fce3aafe..0db2d599b 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts @@ -24,11 +24,24 @@ describe("ipcInvokeTimeoutMs", () => { expect(ipcInvokeTimeoutMs(IPC.localRuntimeStreamEvents)).toBe(150_000); }); - it("keeps ordinary remote runtime actions on the default timeout", () => { + it("gives retryable remote runtime actions enough time to reconnect", () => { expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ id: "target-1", projectId: "project-1", request: { domain: "lane", action: "list" }, + }])).toBe(75_000); + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ + id: "target-1", + projectId: "project-1", + request: { domain: "file", action: "readFile", args: {} }, + }])).toBe(75_000); + }); + + it("keeps ordinary remote runtime actions on the default timeout", () => { + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ + id: "target-1", + projectId: "project-1", + request: { domain: "chat", action: "sendMessage" }, }])).toBe(30_000); }); diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts index a1d43154b..787e65e96 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts @@ -33,6 +33,30 @@ const RUNTIME_ACTION_CHANNEL: Record> = { }; const LOCAL_RUNTIME_PROJECT_SETUP_TIMEOUT_MS = 150_000; +const REMOTE_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 10 * 60_000; +const REMOTE_RUNTIME_RETRYABLE_ACTION_TIMEOUT_MS = 75_000; + +const RETRYABLE_REMOTE_ACTION_PREFIXES = [ + "diagnosticsGet", + "get", + "list", + "oauthGet", + "oauthList", + "portGet", + "portList", + "proxyGet", + "read", + "search", +] as const; + +const RETRYABLE_REMOTE_ACTIONS = new Set([ + "chat.codexFuzzyFileSearch", + "chat.fileSearch", + "chat.modelCatalog", + "file.quickOpen", + "terminal.activeForChat", + "terminal.preview", +]); function runtimeActionTimeoutMs(args: readonly unknown[]): number | null { const payload = args[0]; @@ -42,6 +66,19 @@ function runtimeActionTimeoutMs(args: readonly unknown[]): number | null { return channel ? ipcInvokeTimeoutMs(channel) : null; } +function retryableRemoteActionTimeoutMs(args: readonly unknown[]): number | null { + const payload = args[0]; + const request = isRecord(payload) && isRecord(payload.request) ? payload.request : null; + const domain = request?.domain; + const action = request?.action; + if (typeof domain !== "string" || typeof action !== "string") return null; + const actionKey = `${domain}.${action}`; + if (RETRYABLE_REMOTE_ACTIONS.has(actionKey)) return REMOTE_RUNTIME_RETRYABLE_ACTION_TIMEOUT_MS; + return RETRYABLE_REMOTE_ACTION_PREFIXES.some((prefix) => action.startsWith(prefix)) + ? REMOTE_RUNTIME_RETRYABLE_ACTION_TIMEOUT_MS + : null; +} + export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = []): number { if (channel === IPC.localRuntimeCallAction) { const actionTimeoutMs = runtimeActionTimeoutMs(args); @@ -54,9 +91,26 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [ if (channel === IPC.remoteRuntimeCallAction) { const actionTimeoutMs = runtimeActionTimeoutMs(args); if (actionTimeoutMs != null) return actionTimeoutMs; + const retryableActionTimeoutMs = retryableRemoteActionTimeoutMs(args); + if (retryableActionTimeoutMs != null) return retryableActionTimeoutMs; return 30_000; } switch (channel) { + case IPC.remoteRuntimeConnect: + case IPC.remoteRuntimeListProjects: + case IPC.remoteRuntimeAddProject: + case IPC.remoteRuntimeBrowseDirectories: + case IPC.remoteRuntimeGetProjectDetail: + case IPC.remoteRuntimeGetDefaultParentDir: + case IPC.remoteRuntimeCreateProject: + case IPC.remoteRuntimeCloneProject: + case IPC.remoteRuntimeListMyGitHubRepos: + case IPC.remoteRuntimeOpenProject: + case IPC.remoteRuntimeListActionRegistry: + case IPC.remoteRuntimeCallSync: + case IPC.remoteRuntimeStreamEvents: + case IPC.remoteRuntimeCheckLocalWork: + return REMOTE_RUNTIME_BOOTSTRAP_TIMEOUT_MS; case IPC.lanesCreate: case IPC.lanesCreateChild: case IPC.lanesCreateFromUnstaged: diff --git a/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts b/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts index 15cd73b8d..18c36c2b0 100644 --- a/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts +++ b/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts @@ -48,10 +48,23 @@ export function createRuntimeDiagnosticsService({ function checkPort(port: number, timeoutMs = 500): Promise { return new Promise((resolve) => { const socket = new net.Socket(); + let settled = false; + const settle = (ok: boolean): void => { + if (settled) return; + settled = true; + socket.removeAllListeners?.("connect"); + socket.removeAllListeners?.("timeout"); + try { socket.destroy(); } catch { /* ignore */ } + resolve(ok); + }; socket.setTimeout(timeoutMs); - socket.once("connect", () => { socket.destroy(); resolve(true); }); - socket.once("timeout", () => { socket.destroy(); resolve(false); }); - socket.once("error", () => { socket.destroy(); resolve(false); }); + socket.once("connect", () => settle(true)); + socket.once("timeout", () => settle(false)); + if (typeof socket.on === "function") { + socket.on("error", () => settle(false)); + } else { + socket.once("error", () => settle(false)); + } socket.connect(port, "127.0.0.1"); }); } diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 9b83aee6d..8846f2889 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -233,46 +233,69 @@ function openSocketTransport(socketPath: string, timeoutMs = 3_000): Promise { if (settled) return; settled = true; socket.destroy(); reject(new Error(`Timed out connecting to ADE service socket: ${socketPath}`)); }, timeoutMs); + const closeCallbacks = new Set<() => void>(); + const errorCallbacks = new Set<(error: Error) => void>(); const fail = (error: Error) => { - if (settled) return; - settled = true; - clearTimeout(timer); - socket.destroy(); - reject(error); + if (!connected) { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + reject(error); + return; + } + lastError = error; + for (const callback of [...errorCallbacks]) { + try { + callback(error); + } catch { + // Disconnect observers must not turn a socket reset into a process crash. + } + } }; - socket.once("error", fail); + socket.on("error", fail); socket.once("connect", () => { if (settled) return; settled = true; + connected = true; clearTimeout(timer); - socket.off("error", fail); - const closeCallbacks = new Set<() => void>(); - const errorCallbacks = new Set<(error: Error) => void>(); - socket.on("error", (error) => { - for (const callback of [...errorCallbacks]) { - callback(error); - } - }); socket.on("close", () => { + closed = true; for (const callback of [...closeCallbacks]) { - callback(); + try { + callback(); + } catch { + // Disconnect observers must not turn a socket close into a process crash. + } } }); resolve({ onData(callback) { - socket.on("data", (chunk) => callback(Buffer.from(chunk))); + socket.on("data", (chunk) => { + try { + callback(Buffer.from(chunk)); + } catch { + socket.destroy(); + } + }); }, onClose(callback) { closeCallbacks.add(callback); + if (closed) queueMicrotask(callback); }, onError(callback) { errorCallbacks.add(callback); + const error = lastError; + if (error) queueMicrotask(() => callback(error)); }, write(data) { socket.write(data); diff --git a/apps/desktop/src/main/services/probeLocalhostPort.ts b/apps/desktop/src/main/services/probeLocalhostPort.ts index c8a5980e5..c8ee0513b 100644 --- a/apps/desktop/src/main/services/probeLocalhostPort.ts +++ b/apps/desktop/src/main/services/probeLocalhostPort.ts @@ -13,11 +13,9 @@ export async function probeLocalhostPort( const settle = (value: boolean) => { if (settled) return; settled = true; - try { - socket.destroy(); - } catch { - // ignore - } + socket.removeAllListeners("connect"); + socket.removeAllListeners("timeout"); + try { socket.destroy(); } catch { /* ignore */ } resolve(value); }; @@ -25,6 +23,6 @@ export async function probeLocalhostPort( socket.setTimeout(timeoutMs); socket.once("connect", () => settle(true)); socket.once("timeout", () => settle(false)); - socket.once("error", () => settle(false)); + socket.on("error", () => settle(false)); }); } diff --git a/apps/desktop/src/main/services/processes/processService.ts b/apps/desktop/src/main/services/processes/processService.ts index b1eaef438..662cdfe33 100644 --- a/apps/desktop/src/main/services/processes/processService.ts +++ b/apps/desktop/src/main/services/processes/processService.ts @@ -120,17 +120,15 @@ function checkPortReady(port: number): Promise { const settle = (ok: boolean) => { if (settled) return; settled = true; - try { - socket.destroy(); - } catch { - // ignore - } + socket.removeAllListeners("connect"); + socket.removeAllListeners("timeout"); + try { socket.destroy(); } catch { /* ignore */ } resolve(ok); }; socket.setTimeout(600); socket.once("connect", () => settle(true)); socket.once("timeout", () => settle(false)); - socket.once("error", () => settle(false)); + socket.on("error", () => settle(false)); }); } diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 8434eed2f..d6fce7be6 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -1,7 +1,9 @@ import crypto from "node:crypto"; +import { EventEmitter } from "node:events"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { PassThrough } from "node:stream"; import type { Client } from "ssh2"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; @@ -301,16 +303,21 @@ function createTempResources( } function createFakeSsh() { - const sftpEnd = vi.fn(); - const fastPut = vi.fn((_localPath: string, _remotePath: string, _options: object, callback: (error?: Error | null) => void) => { - callback(null); - }); - const sftp = vi.fn((callback: (error: Error | null, sftp: { fastPut: typeof fastPut; end: typeof sftpEnd }) => void) => { - callback(null, { fastPut, end: sftpEnd }); + const exec = vi.fn((command: string, callback: (error: Error | null, channel: PassThrough & { stderr: PassThrough }) => void) => { + const channel = new PassThrough() as PassThrough & { stderr: PassThrough }; + channel.stderr = new PassThrough(); + channel.resume(); + channel.on("finish", () => { + setImmediate(() => { + channel.emit("exit", 0, null); + channel.emit("close", 0, null); + }); + }); + callback(null, channel); }); const end = vi.fn(); - const ssh = { sftp, end } as unknown as Client; - return { ssh, sftp, fastPut, sftpEnd, end }; + const ssh = Object.assign(new EventEmitter(), { exec, end }) as unknown as Client; + return { ssh, exec, end }; } function createRegistry() { @@ -400,8 +407,13 @@ describe("bootstrapRemoteRuntime upload flow", () => { if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(""); if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); - if (command === "mkdir -p $HOME/.ade/bin") return ok(""); - if (command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version")) return ok(""); + if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade.upload-") && + command.includes("mv -f $HOME/.ade/bin/ade.upload-") && + command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version") + ) return ok(""); if (command.includes("$HOME/.ade/bin/ade --version")) return ok("ade 2.0.0\n"); if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); throw new Error(`Unexpected SSH command: ${command}`); @@ -415,17 +427,28 @@ describe("bootstrapRemoteRuntime upload flow", () => { }); expect(connectSshWithRouteMock).toHaveBeenCalledWith(uploadTarget); - expect(fakeSsh.fastPut).toHaveBeenCalledWith(resources.binaryPath, ".ade/bin/ade", {}, expect.any(Function)); - expect(commands).toEqual([ + expect(fakeSsh.exec).toHaveBeenCalledWith( + expect.stringMatching(/^umask 077; cat > \$HOME\/\.ade\/bin\/ade\.upload-.*\.tmp$/), + expect.any(Function), + ); + expect(commands.slice(0, 4)).toEqual([ "uname -sm", "cat $HOME/.ade/bin/ade.version 2>/dev/null || true", "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true", "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true", - "mkdir -p $HOME/.ade/bin", - `chmod 700 $HOME/.ade/bin && chmod +x $HOME/.ade/bin/ade && printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version && printf '%s\\n' '${resources.binarySha256}' > $HOME/.ade/bin/ade.sha256 && chmod 600 $HOME/.ade/bin/ade.version && chmod 600 $HOME/.ade/bin/ade.sha256`, + ]); + expect(commands).toContain("mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin"); + expect(commands.some((command) => + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes(`printf '%s\\n' '${resources.binarySha256}' > $HOME/.ade/bin/ade.sha256`) && + command.includes("mv -f $HOME/.ade/bin/ade.upload-"), + )).toBe(true); + expect(commands).toContain( 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade --version', + ); + expect(commands).toContain( 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade runtime stop --text >/dev/null 2>&1 || true', - ]); + ); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade rpc --stdio', @@ -467,8 +490,12 @@ describe("bootstrapRemoteRuntime upload flow", () => { if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(""); if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); - if (command === "mkdir -p $HOME/.ade/bin") return ok(""); - if (command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version")) return ok(""); + if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes("mv -f $HOME/.ade/bin/ade.upload-") && + command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version") + ) return ok(""); if (command.includes("$HOME/.ade/bin/ade --version")) return ok("ade 1.9.0\n"); throw new Error(`Unexpected SSH command: ${command}`); }); @@ -480,7 +507,54 @@ describe("bootstrapRemoteRuntime upload flow", () => { appVersion: APP_VERSION, })).rejects.toThrow(/uploaded ade service version mismatch/i); - expect(fakeSsh.fastPut).toHaveBeenCalledWith(resources.binaryPath, ".ade/bin/ade", {}, expect.any(Function)); + expect(fakeSsh.exec).toHaveBeenCalledWith( + expect.stringMatching(/^umask 077; cat > \$HOME\/\.ade\/bin\/ade\.upload-.*\.tmp$/), + expect.any(Function), + ); + expect(openSshRuntimeTransportMock).not.toHaveBeenCalled(); + expect(initializeMock).not.toHaveBeenCalled(); + expect(registry.update).not.toHaveBeenCalled(); + expect(fakeSsh.end).toHaveBeenCalledTimes(1); + }); + + it("fails closed when SSH disconnects during runtime upload", async () => { + const resources = createTempResources(); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + fakeSsh.exec.mockImplementation((_command: string, callback: (error: Error | null, channel: PassThrough & { stderr: PassThrough }) => void) => { + const channel = new PassThrough() as PassThrough & { stderr: PassThrough }; + channel.stderr = new PassThrough(); + channel.resume(); + callback(null, channel); + setImmediate(() => { + (fakeSsh.ssh as unknown as EventEmitter).emit("close"); + }); + }); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + }); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(""); + if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); + if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if (command.startsWith("rm -f $HOME/.ade/bin/ade.upload-")) return ok(""); + throw new Error(`Unexpected SSH command: ${command}`); + }); + + await expect(bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + })).rejects.toThrow(/ssh connection closed while uploading ade service artifact/i); + + expect(commands.some((command) => command.startsWith("rm -f $HOME/.ade/bin/ade.upload-"))).toBe(true); expect(openSshRuntimeTransportMock).not.toHaveBeenCalled(); expect(initializeMock).not.toHaveBeenCalled(); expect(registry.update).not.toHaveBeenCalled(); @@ -502,11 +576,19 @@ describe("bootstrapRemoteRuntime upload flow", () => { if (command === "cat $HOME/.ade-alpha/bin/ade.version 2>/dev/null || true") return ok(""); if (command === "cat $HOME/.ade-alpha/bin/ade.sha256 2>/dev/null || true") return ok(""); if (command === "test -x $HOME/.ade-alpha/bin/ade && $HOME/.ade-alpha/bin/ade --version || true") return ok(""); - if (command === "mkdir -p $HOME/.ade-alpha/bin") return ok(""); + if (command === "mkdir -p $HOME/.ade-alpha/bin && chmod 700 $HOME/.ade-alpha/bin") return ok(""); if (command === "mkdir -p $HOME/.ade-alpha/runtime") return ok(""); - if (command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade-alpha/bin/ade.version")) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade-alpha/bin/ade.upload-") && + command.includes("mv -f $HOME/.ade-alpha/bin/ade.upload-") && + command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade-alpha/bin/ade.version") + ) return ok(""); if (command.includes("test -d $HOME/.ade-alpha/runtime/darwin-arm64/node_modules")) return ok("ok\n"); - if (command.includes("tar -xzf $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz")) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz.upload-") && + command.includes("mv -f $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz.upload-") && + command.includes("tar -xzf $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz") + ) return ok(""); if (command === "codesign --force --sign - $HOME/.ade-alpha/bin/ade") return ok(""); if (command.includes("$HOME/.ade-alpha/bin/ade --version")) return ok("ade 2.0.0\n"); if (command.includes("$HOME/.ade-alpha/bin/ade runtime stop --text")) return ok(""); @@ -520,7 +602,14 @@ describe("bootstrapRemoteRuntime upload flow", () => { appVersion: APP_VERSION, }); - expect(fakeSsh.fastPut).toHaveBeenCalledWith(resources.binaryPath, ".ade-alpha/bin/ade", {}, expect.any(Function)); + expect(fakeSsh.exec).toHaveBeenCalledWith( + expect.stringMatching(/^umask 077; cat > \$HOME\/\.ade-alpha\/bin\/ade\.upload-.*\.tmp$/), + expect.any(Function), + ); + expect(fakeSsh.exec).toHaveBeenCalledWith( + expect.stringMatching(/^umask 077; cat > \$HOME\/\.ade-alpha\/runtime\/ade-darwin-arm64\.native\.tar\.gz\.upload-.*\.tmp$/), + expect.any(Function), + ); expect(execSshMock).toHaveBeenCalledWith(fakeSsh.ssh, "codesign --force --sign - $HOME/.ade-alpha/bin/ade"); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, @@ -572,7 +661,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { appVersion: APP_VERSION, }); - expect(fakeSsh.fastPut).not.toHaveBeenCalled(); + expect(fakeSsh.exec).not.toHaveBeenCalled(); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, 'ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 ade rpc --stdio', @@ -748,7 +837,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { }, }); - expect(fakeSsh.fastPut).not.toHaveBeenCalled(); + expect(fakeSsh.exec).not.toHaveBeenCalled(); expect(openSshRuntimeTransportMock).toHaveBeenCalledTimes(1); expect(commands).not.toContain( 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade runtime stop --text >/dev/null 2>&1 || true', diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index 385d04ad3..f5592f77f 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -267,29 +267,223 @@ function hashRuntimeBinary(localPath: string): string { return crypto.createHash("sha256").update(fs.readFileSync(localPath)).digest("hex"); } -async function uploadRuntimeBinary(client: Client, layout: RemoteRuntimeLayout, localPath: string, appVersion: string, localBinarySha256: string): Promise { - await execSsh(client, `mkdir -p ${layout.binDirExpr}`); +function fileSizeBytes(localPath: string): number { + return fs.statSync(localPath).size; +} + +function remoteUploadTempSuffix(): string { + return `upload-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}.tmp`; +} + +async function execSshOrThrow(client: Client, command: string, fallback: string): Promise { + const result = await execSsh(client, command); + if (result.code === 0) return; + throw new Error(result.stderr.trim() || result.stdout.trim() || fallback); +} + +function remoteSha256Expr(fileExpr: string): string { + return `(command -v shasum >/dev/null 2>&1 && shasum -a 256 ${fileExpr} || sha256sum ${fileExpr}) | awk '{print $1}'`; +} + +function remoteFileMatchesCommand(fileExpr: string, expectedSize: number, expectedSha256: string): string { + return [ + `test "$(wc -c < ${fileExpr} | tr -d '[:space:]')" = ${shellQuote(String(expectedSize))}`, + `test "$(${remoteSha256Expr(fileExpr)})" = ${shellQuote(expectedSha256)}`, + ].join(" && "); +} + +const REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS = 10 * 60_000; +const REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS = 120_000; + +async function uploadSshFile(client: Client, localPath: string, remoteFileExpr: string): Promise { await new Promise((resolve, reject) => { - client.sftp((error, sftp) => { + let settled = false; + let readStream: fs.ReadStream | null = null; + let uploadChannel: (NodeJS.WritableStream & { + stderr?: NodeJS.ReadableStream; + close?: () => void; + destroy?: (error?: Error) => void; + }) | null = null; + let exitCode: number | null = null; + let exitSignal: string | null = null; + let stderr = ""; + let uploadedBytes = 0; + let timeout: NodeJS.Timeout | null = null; + let idleTimeout: NodeJS.Timeout | null = null; + + const uploadProgressSuffix = (): string => + uploadedBytes > 0 ? ` after ${uploadedBytes} bytes` : ""; + + const clearTimers = (): void => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (idleTimeout) { + clearTimeout(idleTimeout); + idleTimeout = null; + } + }; + + const resetIdleTimer = (): void => { + if (idleTimeout) clearTimeout(idleTimeout); + idleTimeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact: no read or drain progress for ${REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS); + idleTimeout.unref?.(); + }; + + const onClientClose = (): void => { + settle(new Error(`SSH connection closed while uploading ADE service artifact${uploadProgressSuffix()}.`)); + }; + const onClientEnd = (): void => { + settle(new Error(`SSH connection ended while uploading ADE service artifact${uploadProgressSuffix()}.`)); + }; + const onClientError = (error: Error): void => { + settle(new Error(`SSH connection failed while uploading ADE service artifact${uploadProgressSuffix()}: ${error.message}`)); + }; + + const removeClientListeners = (): void => { + client.off("close", onClientClose); + client.off("end", onClientEnd); + client.off("error", onClientError); + }; + + const settle = (error?: Error | null): void => { + if (settled) return; + settled = true; + clearTimers(); + removeClientListeners(); if (error) { + try { + readStream?.destroy(); + } catch {} + try { + uploadChannel?.close?.(); + } catch {} + try { + uploadChannel?.destroy?.(error); + } catch {} reject(error); return; } - sftp.fastPut(localPath, layout.binaryRelative, {}, (putError) => { - sftp.end(); - if (putError) reject(putError); - else resolve(); + resolve(); + }; + + client.once("close", onClientClose); + client.once("end", onClientEnd); + client.once("error", onClientError); + + timeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact after ${REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS); + timeout.unref?.(); + resetIdleTimer(); + + client.exec(`umask 077; cat > ${remoteFileExpr}`, (error, channel) => { + if (error) { + settle(error); + return; + } + uploadChannel = channel as typeof uploadChannel; + readStream = fs.createReadStream(localPath); + channel.stderr.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + channel.on("exit", (code: number | null, signal: string | null) => { + exitCode = code; + exitSignal = signal; + }); + channel.on("error", (streamError: Error) => { + settle(streamError); + }); + channel.on("drain", () => { + resetIdleTimer(); + }); + readStream.on("data", (chunk: Buffer | string) => { + uploadedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); + resetIdleTimer(); }); + readStream.on("end", () => { + resetIdleTimer(); + }); + readStream.on("error", (streamError) => { + settle(streamError); + }); + channel.on("close", (code: number | null, signal: string | null) => { + const resolvedCode = code ?? exitCode; + const resolvedSignal = signal ?? exitSignal; + if (resolvedCode && resolvedCode !== 0) { + const detail = stderr.trim() || `remote command exited with code ${resolvedCode}`; + settle(new Error(`Unable to upload ADE service artifact: ${detail}`)); + return; + } + if (resolvedSignal) { + const detail = stderr.trim() || `remote command exited with signal ${resolvedSignal}`; + settle(new Error(`Unable to upload ADE service artifact: ${detail}`)); + return; + } + settle(null); + }); + readStream.pipe(channel); }); }); - await execSsh(client, [ - `chmod 700 ${layout.binDirExpr}`, - `chmod +x ${layout.binaryExpr}`, - `printf '%s\\n' ${shellQuote(appVersion)} > ${layout.versionExpr}`, - `printf '%s\\n' ${shellQuote(localBinarySha256)} > ${layout.sha256Expr}`, - `chmod 600 ${layout.versionExpr}`, - `chmod 600 ${layout.sha256Expr}`, - ].join(" && ")); +} + +async function uploadRuntimeBinary(client: Client, layout: RemoteRuntimeLayout, localPath: string, appVersion: string, localBinarySha256: string): Promise { + const tempSuffix = remoteUploadTempSuffix(); + const tempExpr = `${layout.binaryExpr}.${tempSuffix}`; + await execSshOrThrow( + client, + `mkdir -p ${layout.binDirExpr} && chmod 700 ${layout.binDirExpr}`, + "Unable to create the remote ADE service directory.", + ); + try { + await uploadSshFile(client, localPath, tempExpr); + await execSshOrThrow(client, [ + remoteFileMatchesCommand(tempExpr, fileSizeBytes(localPath), localBinarySha256), + `chmod +x ${tempExpr}`, + `mv -f ${tempExpr} ${layout.binaryExpr}`, + `printf '%s\\n' ${shellQuote(appVersion)} > ${layout.versionExpr}`, + `printf '%s\\n' ${shellQuote(localBinarySha256)} > ${layout.sha256Expr}`, + `chmod 600 ${layout.versionExpr}`, + `chmod 600 ${layout.sha256Expr}`, + ].join(" && "), "Uploaded ADE service did not pass size and checksum verification."); + } catch (error) { + await execSsh(client, `rm -f ${tempExpr}`).catch(() => okIgnored()); + throw error; + } +} + +function okIgnored(): void {} + +async function uploadNativeDepsBundle(client: Client, layout: RemoteRuntimeLayout, archLabel: string, localPath: string, appVersion: string): Promise { + const localSha256 = hashRuntimeBinary(localPath); + const remoteArchiveExpr = `${layout.runtimeDirExpr}/ade-${archLabel}.native.tar.gz`; + const tempSuffix = remoteUploadTempSuffix(); + const tempExpr = `${remoteArchiveExpr}.${tempSuffix}`; + await execSshOrThrow( + client, + `mkdir -p ${layout.runtimeDirExpr}`, + "Unable to create the remote ADE native dependency directory.", + ); + try { + await uploadSshFile(client, localPath, tempExpr); + const extract = await execSsh(client, [ + remoteFileMatchesCommand(tempExpr, fileSizeBytes(localPath), localSha256), + `mv -f ${tempExpr} ${remoteArchiveExpr}`, + `rm -rf ${layout.runtimeDirExpr}/${archLabel}`, + `mkdir -p ${layout.runtimeDirExpr}/${archLabel}`, + `tar -xzf ${remoteArchiveExpr} -C ${layout.runtimeDirExpr}/${archLabel}`, + `printf '%s\\n' ${shellQuote(appVersion)} > ${layout.runtimeDirExpr}/${archLabel}/.ade-version`, + ].join(" && ")); + if (extract.code !== 0) { + throw new Error(extract.stderr.trim() || "Unable to unpack ADE service native dependencies on the remote machine."); + } + } catch (error) { + await execSsh(client, `rm -f ${tempExpr}`).catch(() => okIgnored()); + throw error; + } } async function signUploadedRuntimeBinaryIfNeeded(client: Client, layout: RemoteRuntimeLayout, platform: string): Promise { @@ -304,33 +498,6 @@ async function signUploadedRuntimeBinaryIfNeeded(client: Client, layout: RemoteR } } -async function uploadNativeDepsBundle(client: Client, layout: RemoteRuntimeLayout, archLabel: string, localPath: string, appVersion: string): Promise { - await execSsh(client, `mkdir -p ${layout.runtimeDirExpr}`); - const remoteArchive = `${layout.runtimeDirRelative}/ade-${archLabel}.native.tar.gz`; - await new Promise((resolve, reject) => { - client.sftp((error, sftp) => { - if (error) { - reject(error); - return; - } - sftp.fastPut(localPath, remoteArchive, {}, (putError) => { - sftp.end(); - if (putError) reject(putError); - else resolve(); - }); - }); - }); - const extract = await execSsh(client, [ - `rm -rf ${layout.runtimeDirExpr}/${archLabel}`, - `mkdir -p ${layout.runtimeDirExpr}/${archLabel}`, - `tar -xzf ${layout.runtimeDirExpr}/ade-${archLabel}.native.tar.gz -C ${layout.runtimeDirExpr}/${archLabel}`, - `printf '%s\\n' ${shellQuote(appVersion)} > ${layout.runtimeDirExpr}/${archLabel}/.ade-version`, - ].join(" && ")); - if (extract.code !== 0) { - throw new Error(extract.stderr.trim() || "Unable to unpack ADE service native dependencies on the remote machine."); - } -} - async function stopRemoteRuntimeDaemon(client: Client, layout: RemoteRuntimeLayout, runtimeEnvPrefix: string): Promise { await execSsh( client, diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts index cd93f9d8f..203cb27a2 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts @@ -490,11 +490,11 @@ describe("RemoteConnectionPool", () => { expect(secondClient.call).not.toHaveBeenCalled(); }); - it("retries read-only project actions once after reconnecting", async () => { + it("retries read-only project actions once after ECONNRESET", async () => { const firstClient = createClient(); const firstSsh = createSsh(); firstClient.call.mockRejectedValueOnce( - new Error("Remote runtime connection failed: stream closed"), + new Error("read ECONNRESET"), ); bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ client: firstClient, @@ -538,7 +538,7 @@ describe("RemoteConnectionPool", () => { domain: "lane", action: "list", }, - }); + }, { timeoutMs: 25_000 }); expect(secondClient.call).toHaveBeenCalledTimes(1); expect(secondClient.call).toHaveBeenCalledWith("ade/actions/call", { projectId: "project-1", @@ -547,7 +547,7 @@ describe("RemoteConnectionPool", () => { domain: "lane", action: "list", }, - }); + }, { timeoutMs: 25_000 }); }); it("lists remote ADE actions as grouped registry entries", async () => { @@ -651,7 +651,7 @@ describe("RemoteConnectionPool", () => { domain: "lane", action: "list", }, - }); + }, { timeoutMs: 25_000 }); }); it("calls project-scoped sync methods on the connected runtime", async () => { diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts index f88815cd8..44cbc9c9c 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts @@ -32,11 +32,13 @@ type RuntimeEventNotification = { function isRemoteRuntimeConnectionError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); - return /remote (?:runtime|ADE service) connection (?:closed|failed)|timed out waiting for method|stream closed|channel closed|connection lost|socket closed/i.test( + return /remote (?:runtime|ADE service) connection (?:closed|failed)|timed out waiting for method|stream closed|channel closed|connection lost|socket closed|ECONNRESET|ECONNABORTED|EPIPE|ENOTCONN/i.test( message, ); } +const RETRYABLE_REMOTE_ACTION_RPC_TIMEOUT_MS = 25_000; + const RETRYABLE_REMOTE_ACTION_PREFIXES = [ "diagnosticsGet", "get", @@ -102,6 +104,14 @@ function shouldRetryRemoteRuntimeAction( ); } +function remoteRuntimeActionCallOptions( + request: RemoteRuntimeActionRequest, +): { timeoutMs?: number } | undefined { + return shouldRetryRemoteRuntimeAction(request) + ? { timeoutMs: RETRYABLE_REMOTE_ACTION_RPC_TIMEOUT_MS } + : undefined; +} + function assertMachineProjectCapability(entry: PoolEntry, method: string): void { const capability = MACHINE_PROJECT_METHOD_CAPABILITY.get(method); if (!capability) return; @@ -310,7 +320,7 @@ export class RemoteConnectionPool { projectId: string, request: RemoteRuntimeActionRequest, ): Promise { - const value = await entry.client.call("ade/actions/call", { + const params = { projectId, name: "run_ade_action", arguments: { @@ -322,7 +332,11 @@ export class RemoteConnectionPool { : {}), ...(request.argsList ? { argsList: request.argsList } : {}), }, - }); + }; + const callOptions = remoteRuntimeActionCallOptions(request); + const value = callOptions + ? await entry.client.call("ade/actions/call", params, callOptions) + : await entry.client.call("ade/actions/call", params); if (value && typeof value === "object" && !Array.isArray(value)) { const record = value as Record; diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts index 3345213f4..56ef432b0 100644 --- a/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts @@ -205,7 +205,11 @@ export class RuntimeRpcClient { this.closedError = error; this.rejectAll(error); for (const callback of this.disconnectCallbacks) { - callback(error); + try { + callback(error); + } catch { + // Disconnect observers are best-effort; the connection is already failed. + } } this.disconnectCallbacks.clear(); } diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts index fb709217a..2c6606240 100644 --- a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts @@ -686,9 +686,54 @@ function isSshAuthenticationFailure(error: unknown): boolean { function connectSshWithConfig(config: ConnectConfig): Promise { return new Promise((resolve, reject) => { const client = new Client(); - client.once("ready", () => resolve(client)); - client.once("error", reject); - client.connect(config); + let settled = false; + const normalizeError = (error: unknown, fallback: string): Error => + error instanceof Error ? error : new Error(String(error ?? fallback)); + const cleanupConnectListeners = (options: { keepErrorListener?: boolean } = {}): void => { + client.off("ready", onReady); + if (!options.keepErrorListener) { + client.off("error", onError); + } + client.off("close", onClose); + client.off("end", onEnd); + }; + const fail = (error: unknown, fallback: string): void => { + if (settled) return; + settled = true; + // ssh2 may emit a late socket error after close/end during a failed + // connect attempt. Keep onError attached as a sink so the late error is + // contained after the promise has already rejected. + cleanupConnectListeners({ keepErrorListener: true }); + reject(normalizeError(error, fallback)); + }; + const onReady = (): void => { + if (settled) return; + settled = true; + cleanupConnectListeners(); + // ssh2 can surface transport resets after ready and before the remote + // pool attaches its lifecycle listener. Keep a durable listener so those + // errors remain contained instead of becoming process-level exceptions. + client.on("error", () => {}); + resolve(client); + }; + const onError = (error: Error): void => { + fail(error, "SSH connection failed."); + }; + const onClose = (): void => { + fail(null, "SSH connection closed before it became ready."); + }; + const onEnd = (): void => { + fail(null, "SSH connection ended before it became ready."); + }; + client.once("ready", onReady); + client.on("error", onError); + client.once("close", onClose); + client.once("end", onEnd); + try { + client.connect(config); + } catch (error) { + fail(error, "SSH connection failed."); + } }); } diff --git a/scripts/package-channel.mjs b/scripts/package-channel.mjs index 7a3880d4d..216f0b745 100644 --- a/scripts/package-channel.mjs +++ b/scripts/package-channel.mjs @@ -304,6 +304,19 @@ function postprocessChannelApp(appPath, channel, config, options) { fs.writeFileSync(path.join(cliRoot, "channel"), `${channel}\n`); } +function adHocSignLocalMacApp(appPath, options) { + if (process.platform !== "darwin") return; + process.stdout.write(`[ade] Ad-hoc signing local app bundle: ${appPath}\n`); + run("codesign", ["--force", "--deep", "--sign", "-", "--timestamp=none", appPath], { + cwd: path.dirname(appPath), + dryRun: options.dryRun, + }); + run("codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath], { + cwd: path.dirname(appPath), + dryRun: options.dryRun, + }); +} + function buildChannel(repoRoot, channel, options) { const config = CHANNELS[channel]; ensureRepoRoot(repoRoot, options); @@ -352,6 +365,7 @@ function buildChannel(repoRoot, channel, options) { const appPath = findBuiltApp(outputRoot, config.productName); if (!appPath) fail(`Build finished but no .app was found in ${outputRoot}.`); postprocessChannelApp(appPath, channel, config, options); + adHocSignLocalMacApp(appPath, options); const zipPath = zipApp(appPath, outputRoot, channel, options); process.stdout.write(`\n[ade] Built ${config.productName}: ${appPath}\n`); if (zipPath) process.stdout.write(`[ade] Zipped app: ${zipPath}\n`); From 46aa78aaecf0c696ff0233205f306f6689b18d3a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 04:13:58 -0400 Subject: [PATCH 02/80] fix(remote): reduce idle renderer chatter --- apps/desktop/src/preload/preload.test.ts | 155 ++++++++++++++++++ apps/desktop/src/preload/preload.ts | 51 +++++- .../src/renderer/components/app/AppShell.tsx | 12 +- .../components/app/LinearQuickViewButton.tsx | 28 ++-- .../src/renderer/components/app/TabNav.tsx | 8 +- .../renderer/components/app/TopBar.test.tsx | 55 +++++++ .../renderer/components/lanes/LanesPage.tsx | 84 ++++++---- 7 files changed, 336 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index a3a64b907..fc1a62044 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -1933,6 +1933,92 @@ describe("preload OAuth bridge", () => { } }); + it("ignores stale stream errors after switching event polling bindings", async () => { + vi.useFakeTimers(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const localBinding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const remoteBinding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/repo", + displayName: "Project", + }; + let binding: typeof localBinding | typeof remoteBinding = localBinding; + let rejectLocalStream = (_error: Error): void => { + throw new Error("local stream was not started"); + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + if (channel === IPC.localRuntimeStreamEvents) { + return await new Promise((_resolve, reject) => { + rejectLocalStream = reject; + }); + } + if (channel === IPC.remoteRuntimeStreamEvents) { + return { events: [], nextCursor: 0, hasMore: false }; + } + throw new Error(`unexpected IPC: ${channel}`); + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + const unsubscribeState = bridge.project.onStateEvent(vi.fn()); + const unsubscribeBinding = bridge.app.onProjectBindingChanged(vi.fn()); + await vi.advanceTimersByTimeAsync(0); + + const localStreamCallCount = () => + invoke.mock.calls.filter(([channel]) => channel === IPC.localRuntimeStreamEvents).length; + const remoteStreamCallCount = () => + invoke.mock.calls.filter(([channel]) => channel === IPC.remoteRuntimeStreamEvents).length; + expect(localStreamCallCount()).toBe(1); + expect(remoteStreamCallCount()).toBe(0); + + const bindingListener = on.mock.calls.find(([channel]) => channel === IPC.appProjectBindingChanged)?.[1]; + expect(typeof bindingListener).toBe("function"); + binding = remoteBinding; + bindingListener({}, remoteBinding); + rejectLocalStream?.(new Error("Local runtime project is not available for this window.")); + await vi.advanceTimersByTimeAsync(0); + + expect(warn).not.toHaveBeenCalled(); + expect(remoteStreamCallCount()).toBe(1); + + unsubscribeBinding(); + unsubscribeState(); + } finally { + warn.mockRestore(); + vi.useRealTimers(); + } + }); + it("routes local project usage reads through the shared project runtime when bound", async () => { const binding = { kind: "local", @@ -4094,6 +4180,75 @@ describe("preload OAuth bridge", () => { } }); + it("backs off idle remote runtime event polling after empty batches", async () => { + vi.useFakeTimers(); + try { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const streamRequests: unknown[] = []; + const invoke = vi.fn(async (channel: string, arg?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeStreamEvents) { + streamRequests.push(arg); + return { + events: [], + nextCursor: 0, + hasMore: false, + eventEpoch: "epoch-a", + }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + const unsubscribe = bridge.project.onStateEvent(vi.fn()); + + await vi.advanceTimersByTimeAsync(0); + expect(streamRequests).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(2_499); + expect(streamRequests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(streamRequests).toHaveLength(2); + + await vi.advanceTimersByTimeAsync(4_999); + expect(streamRequests).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1); + expect(streamRequests).toHaveLength(3); + + unsubscribe(); + } finally { + vi.useRealTimers(); + } + }); + it("drops stale remote runtime catch-up when the project binding changes mid-poll", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 9dab94555..35a6d76fe 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1584,8 +1584,14 @@ let remoteRuntimeEventBindingKey: string | null = null; let remoteRuntimeEventGeneration = -1; let remoteRuntimeEventEpoch: string | null = null; let remoteRuntimeEventStartedAtMs = 0; +let remoteRuntimeEmptyPollCount = 0; let remoteRuntimeSeenEventBindingKey: string | null = null; const remoteRuntimeSeenEventIds = new Set(); +const LOCAL_RUNTIME_EVENT_IDLE_POLL_MS = 750; +const REMOTE_RUNTIME_EVENT_ACTIVE_POLL_MS = 750; +const REMOTE_RUNTIME_EVENT_INITIAL_IDLE_POLL_MS = 2_500; +const REMOTE_RUNTIME_EVENT_IDLE_POLL_MS = 5_000; +const REMOTE_RUNTIME_EVENT_CATCH_UP_POLL_MS = 50; function clearPendingRemoteRuntimeEventPoll(): void { if (!remoteRuntimeEventTimer) return; @@ -1598,6 +1604,10 @@ function resetRemoteRuntimeEventDedup(bindingKey: string | null): void { remoteRuntimeSeenEventIds.clear(); } +function resetRemoteRuntimeEmptyPolls(): void { + remoteRuntimeEmptyPollCount = 0; +} + function shouldDispatchRemoteRuntimeEvent( bindingKey: string, event: RemoteRuntimeBufferedEvent, @@ -1699,6 +1709,8 @@ async function pollRemoteRuntimeEvents(): Promise { if (remoteRuntimeEventInFlight || !hasRemoteRuntimeEventSubscribers()) return; remoteRuntimeEventInFlight = true; let nextDelayMs: number | null = null; + let pollingBindingKey: string | null = null; + let pollingGeneration = projectBindingGeneration; try { const binding = await getProjectRuntimeBinding(); if (!binding) { @@ -1707,6 +1719,7 @@ async function pollRemoteRuntimeEvents(): Promise { remoteRuntimeEventGeneration = projectBindingGeneration; remoteRuntimeEventEpoch = null; remoteRuntimeEventStartedAtMs = 0; + resetRemoteRuntimeEmptyPolls(); resetRemoteRuntimeEventDedup(null); return; } @@ -1721,11 +1734,12 @@ async function pollRemoteRuntimeEvents(): Promise { remoteRuntimeEventEpoch = null; remoteRuntimeEventStartedAtMs = binding.kind === "local" ? Date.now() : 0; + resetRemoteRuntimeEmptyPolls(); resetRemoteRuntimeEventDedup(binding.key); } - const pollingBindingKey = binding.key; - const pollingGeneration = projectBindingGeneration; + pollingBindingKey = binding.key; + pollingGeneration = projectBindingGeneration; const request = { cursor: remoteRuntimeEventCursor, limit: 100, @@ -1761,6 +1775,7 @@ async function pollRemoteRuntimeEvents(): Promise { remoteRuntimeEventEpoch = batchEpoch; if (epochChanged) { remoteRuntimeEventCursor = 0; + resetRemoteRuntimeEmptyPolls(); resetRemoteRuntimeEventDedup(binding.key); nextDelayMs = 0; return; @@ -1784,10 +1799,35 @@ async function pollRemoteRuntimeEvents(): Promise { if (!shouldDispatchRemoteRuntimeEvent(binding.key, event)) continue; dispatchRemoteRuntimeEventPayload(event.payload); } - nextDelayMs = batch.hasMore ? 50 : 750; + if (batch.hasMore) { + resetRemoteRuntimeEmptyPolls(); + nextDelayMs = REMOTE_RUNTIME_EVENT_CATCH_UP_POLL_MS; + } else if (batch.events.length > 0) { + resetRemoteRuntimeEmptyPolls(); + nextDelayMs = + binding.kind === "remote" + ? REMOTE_RUNTIME_EVENT_ACTIVE_POLL_MS + : LOCAL_RUNTIME_EVENT_IDLE_POLL_MS; + } else if (binding.kind === "remote") { + remoteRuntimeEmptyPollCount += 1; + nextDelayMs = + remoteRuntimeEmptyPollCount <= 1 + ? REMOTE_RUNTIME_EVENT_INITIAL_IDLE_POLL_MS + : REMOTE_RUNTIME_EVENT_IDLE_POLL_MS; + } else { + nextDelayMs = LOCAL_RUNTIME_EVENT_IDLE_POLL_MS; + } } catch (error) { - console.warn("ADE runtime event polling failed", error); - nextDelayMs = 2_000; + const stalePoll = + pollingBindingKey != null && + (currentProjectBinding?.key !== pollingBindingKey || + projectBindingGeneration !== pollingGeneration); + if (stalePoll) { + nextDelayMs = 0; + } else { + console.warn("ADE runtime event polling failed", error); + nextDelayMs = 2_000; + } } finally { remoteRuntimeEventInFlight = false; if ( @@ -1806,6 +1846,7 @@ function handleRemoteRuntimeEventNotification(value: unknown): void { const payload = toRemoteRuntimeEventNotificationPayload(value); const binding = currentProjectBinding; if (!payload || !binding || payload.bindingKey !== binding.key) return; + resetRemoteRuntimeEmptyPolls(); const eventTime = Date.parse(payload.event.timestamp); if ( binding.kind === "local" && diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index bd4aefc91..93dc676bc 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -252,6 +252,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const keybindings = useAppStore((s) => s.keybindings); const lanes = useAppStore((s) => s.lanes); const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); const projectRevision = useAppStore((s) => s.projectRevision); const setShowWelcome = useAppStore((s) => s.setShowWelcome); const showWelcome = useAppStore((s) => s.showWelcome); @@ -294,7 +295,8 @@ export function AppShell({ children }: { children: React.ReactNode }) { const dismissedGithubBannerRoots = useAppStore((s) => s.dismissedGithubBannerRoots); const dismissMissingAiBanner = useAppStore((s) => s.dismissMissingAiBanner); const dismissGithubBanner = useAppStore((s) => s.dismissGithubBanner); - const currentProjectRoot = project?.rootPath ?? null; + const currentProjectRoot = + projectBinding?.kind === "remote" ? projectBinding.rootPath : (project?.rootPath ?? null); const missingAiBannerDismissed = Boolean( currentProjectRoot && dismissedMissingAiBannerRoots[currentProjectRoot], ); @@ -311,7 +313,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const isLanesRoute = location.pathname.startsWith("/lanes"); const isLanesRouteRef = useRef(isLanesRoute); const shouldTrackTerminalAttention = - Boolean(project?.rootPath) && + Boolean(currentProjectRoot) && !showWelcome && (location.pathname === "/work" || location.pathname === "/lanes"); @@ -322,17 +324,17 @@ export function AppShell({ children }: { children: React.ReactNode }) { useEffect(() => { logRendererDebugEvent("renderer.route_change", { pathname: location.pathname, - projectRoot: project?.rootPath ?? null, + projectRoot: currentProjectRoot, showWelcome, }); console.info( `renderer.route_change ${JSON.stringify({ pathname: location.pathname, - projectRoot: project?.rootPath ?? null, + projectRoot: currentProjectRoot, showWelcome, })}`, ); - }, [location.pathname, project?.rootPath, showWelcome]); + }, [currentProjectRoot, location.pathname, showWelcome]); useEffect(() => { disposeTerminalRuntimesForProjectChange(project?.rootPath ?? null, projectRevision); diff --git a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx index da5894b2f..ecd325409 100644 --- a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx +++ b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx @@ -45,6 +45,7 @@ import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; const INITIAL_VISIBILITY_CHECK_DELAY_MS = 2_000; const VISIBILITY_RETRY_INTERVAL_MS = 3_000; +const REMOTE_VISIBILITY_RETRY_INTERVAL_MS = 15_000; const VISIBILITY_CONNECTED_CACHE_TTL_MS = 60_000; const VISIBILITY_DISCONNECTED_CACHE_TTL_MS = 1_500; @@ -109,6 +110,7 @@ export function LinearQuickViewButton({ onMenuActivate?: () => void; } = {}) { const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); const lanes = useAppStore((s) => s.lanes); const refreshLanes = useAppStore((s) => s.refreshLanes); const selectLane = useAppStore((s) => s.selectLane); @@ -130,14 +132,20 @@ export function LinearQuickViewButton({ const occludesNativeBrowser = open || batchModalOpen; // Remembers each issue's chosen config so "Retry failed" reuses the same model. const batchConfigByIssueRef = useRef>(new Map()); + const activeProjectRoot = + projectBinding?.kind === "remote" ? projectBinding.rootPath : project?.rootPath; + const visibilityRetryIntervalMs = + projectBinding?.kind === "remote" + ? REMOTE_VISIBILITY_RETRY_INTERVAL_MS + : VISIBILITY_RETRY_INTERVAL_MS; const loadVisibility = useCallback(async (options?: { force?: boolean }): Promise => { return readLinearVisibilityCached({ - projectRoot: project?.rootPath, + projectRoot: activeProjectRoot, reader: window.ade.cto?.getLinearConnectionStatus, force: options?.force === true, }); - }, [project?.rootPath]); + }, [activeProjectRoot]); const openLinearSettings = useCallback(() => { setConnectionPrompt(null); @@ -199,7 +207,7 @@ export function LinearQuickViewButton({ cancelled = true; window.clearTimeout(timer); }; - }, [loadVisibility, project?.rootPath, variant]); + }, [loadVisibility, activeProjectRoot, variant]); useEffect(() => { if (variant !== "icon") return; @@ -232,7 +240,7 @@ export function LinearQuickViewButton({ useEffect(() => { if (variant !== "icon") return; - if (!project?.rootPath) return; + if (!activeProjectRoot) return; let cancelled = false; const refresh = () => { void loadVisibility({ force: true }) @@ -248,12 +256,12 @@ export function LinearQuickViewButton({ cancelled = true; window.removeEventListener("focus", refresh); }; - }, [loadVisibility, project?.rootPath, variant]); + }, [loadVisibility, activeProjectRoot, variant]); useEffect(() => { if (variant !== "icon") return; if (visible) return; - if (!project?.rootPath) return; + if (!activeProjectRoot) return; let cancelled = false; const interval = window.setInterval(() => { void loadVisibility().then((v) => { @@ -262,12 +270,12 @@ export function LinearQuickViewButton({ window.clearInterval(interval); } }).catch(() => {}); - }, VISIBILITY_RETRY_INTERVAL_MS); + }, visibilityRetryIntervalMs); return () => { cancelled = true; window.clearInterval(interval); }; - }, [loadVisibility, visible, project?.rootPath, variant]); + }, [loadVisibility, visible, activeProjectRoot, visibilityRetryIntervalMs, variant]); const openQuickView = useCallback(() => { if (cachedQuickViewRef.current) { @@ -277,9 +285,9 @@ export function LinearQuickViewButton({ }, []); const close = useCallback(() => { - clearLinearQuickViewSelection(project?.rootPath); + clearLinearQuickViewSelection(activeProjectRoot); setOpen(false); - }, [project?.rootPath]); + }, [activeProjectRoot]); useEffect(() => { if (!open) return; diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index be121ca83..253e0d601 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -52,11 +52,14 @@ function githubProfileUrl(login: string): string { export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) { const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); const showWelcome = useAppStore((s) => s.showWelcome); const terminalAttention = useAppStore((s) => s.terminalAttention); const macosVmTabIndicator = useAppStore((s) => s.macosVmTabIndicator); const location = useLocation(); - const hasActiveProject = Boolean(project?.rootPath); + const activeProjectRoot = + projectBinding?.kind === "remote" ? projectBinding.rootPath : (project?.rootPath ?? null); + const hasActiveProject = Boolean(activeProjectRoot); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [avatarBroken, setAvatarBroken] = useState(false); const [isPackaged, setIsPackaged] = useState(false); @@ -99,7 +102,7 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) const onWelcomeLanding = showWelcome || !hasActiveProject; const isActive = !onWelcomeLanding && primaryTabPath(location.pathname) === it.to; const isActiveAllowed = !showWelcome && hasActiveProject; - const navTarget = it.to === "/prs" ? readStoredPrsRoute(project?.rootPath) ?? it.to : it.to; + const navTarget = it.to === "/prs" ? readStoredPrsRoute(activeProjectRoot) ?? it.to : it.to; if (!isActiveAllowed) { return ( @@ -130,6 +133,7 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) data-active={isActive ? "true" : undefined} onClick={() => { logRendererDebugEvent("renderer.tab_nav.click", { + projectRoot: activeProjectRoot, from: location.pathname, to: navTarget, showWelcome, diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index e1e6f86eb..037ba049d 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -1041,6 +1041,61 @@ describe("TopBar", () => { } }); + it("paces hidden Linear quick view retries on remote projects", async () => { + vi.useFakeTimers(); + useAppStore.setState({ + project: null, + projectBinding: { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Mac Studio", + projectId: "project-1", + rootPath: "/Users/admin/Projects/perf pass", + displayName: "perf pass", + }, + } as any); + const getLinearConnectionStatus = vi.fn(async () => ({ + tokenStored: true, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: "2026-04-22T01:00:00.000Z", + authMode: "manual", + oauthAvailable: true, + tokenExpiresAt: null, + message: "Linear connection check is still starting.", + })); + globalThis.window.ade.cto = { + getLinearConnectionStatus, + } as any; + + try { + render(); + + await act(async () => { + vi.advanceTimersByTime(8_000); + await flushMicrotasks(2); + }); + expect(getLinearConnectionStatus).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(6_000); + await flushMicrotasks(2); + }); + expect(getLinearConnectionStatus).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(1_000); + await flushMicrotasks(2); + }); + expect(getLinearConnectionStatus).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("button", { name: /linear quick view/i })).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it("shows project icon replacement errors", async () => { globalThis.window.ade.project.chooseIcon = vi.fn(async () => { throw new Error("Failed to set project icon: Project icon must be 10 MB or smaller."); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 545a9fb67..7a537e938 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -460,6 +460,16 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const setLaneWorkViewState = useAppStore((s) => s.setLaneWorkViewState); const keybindings = useAppStore((s) => s.keybindings); const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); + const activeProjectRoot = + projectBinding?.kind === "remote" ? projectBinding.rootPath : (project?.rootPath ?? null); + const getActiveProjectRoot = useCallback(() => { + const state = appStore.getState(); + return state.projectBinding?.kind === "remote" + ? state.projectBinding.rootPath + : (state.project?.rootPath ?? null); + }, [appStore]); + const laneProgressProjectRoot = activeProjectRoot; const activeTourId = useOnboardingStore((s) => s.activeTourId); const suppressTourDistractions = activeTourId === "first-journey"; @@ -520,7 +530,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const [attachDescription, setAttachDescription] = useState(""); const [attachBusy, setAttachBusy] = useState(false); const [attachError, setAttachError] = useState(null); - const canCreateLane = Boolean(project?.rootPath); + const canCreateLane = Boolean(activeProjectRoot); const [adoptBusy, setAdoptBusy] = useState(false); const [adoptError, setAdoptError] = useState(null); const [adoptConfirmOpen, setAdoptConfirmOpen] = useState(false); @@ -578,7 +588,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const pendingLaneDeleteRefreshIdsRef = useRef>(new Set()); const laneDeleteRefreshTimerRef = useRef(null); const hydratedLaneDeleteProgressProjectRef = useRef(null); - const deleteProgressProjectRootRef = useRef(project?.rootPath ?? null); + const deleteProgressProjectRootRef = useRef(laneProgressProjectRoot); const activeLanePresenceSignatureRef = useRef(null); // Refs for the onDeleteEvent IPC handler. Capturing high-churn values // (selectedLaneId, lanesById, managedLaneIds, manageOpen) in refs lets the @@ -613,15 +623,19 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const consumedCommitDeepLinkSignatureRef = useRef(null); useEffect(() => { - logRendererDebugEvent("renderer.lanes.page_mount"); + logRendererDebugEvent("renderer.lanes.page_mount", { + projectRoot: activeProjectRoot, + }); return () => { - logRendererDebugEvent("renderer.lanes.page_unmount"); + logRendererDebugEvent("renderer.lanes.page_unmount", { + projectRoot: activeProjectRoot, + }); }; - }, []); + }, [activeProjectRoot]); useEffect(() => { if (!active) return; - const projectRoot = project?.rootPath ?? null; + const projectRoot = laneProgressProjectRoot; const previousProjectRoot = deleteProgressProjectRootRef.current; deleteProgressProjectRootRef.current = projectRoot; hydratedLaneDeleteProgressProjectRef.current = null; @@ -634,7 +648,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { if (previousProjectRoot !== projectRoot) { setDeleteProgressByLaneId({}); } - }, [project?.rootPath, setDeleteProgressByLaneId]); + }, [laneProgressProjectRoot, setDeleteProgressByLaneId]); const laneSnapshotByLaneId = useMemo( () => new Map(laneSnapshots.map((snapshot) => [snapshot.lane.id, snapshot] as const)), @@ -804,14 +818,14 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { if (!syncApi?.setActiveLanePresence) { return; } - const laneIds = active && project?.rootPath ? [...visibleLaneIds] : []; + const laneIds = active && activeProjectRoot ? [...visibleLaneIds] : []; const signature = laneIds.join("\0"); if (activeLanePresenceSignatureRef.current === signature) { return; } activeLanePresenceSignatureRef.current = signature; void syncApi.setActiveLanePresence({ laneIds }).catch(() => {}); - }, [active, project?.rootPath, visibleLaneIds]); + }, [active, activeProjectRoot, visibleLaneIds]); useEffect(() => { const syncApi = window.ade.sync; @@ -906,7 +920,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const refreshAutoRebaseEnabled = useCallback(async () => { try { - const snapshot = await getProjectConfigCached({ projectRoot: project?.rootPath ?? null }); + const snapshot = await getProjectConfigCached({ projectRoot: activeProjectRoot }); const enabled = typeof snapshot.effective.git?.autoRebaseOnHeadChange === "boolean" ? snapshot.effective.git.autoRebaseOnHeadChange @@ -915,7 +929,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { } catch { setAutoRebaseEnabled(false); } - }, [project?.rootPath]); + }, [activeProjectRoot]); const refreshIntegrationProposals = useCallback(async () => { try { @@ -928,10 +942,10 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const refreshLanePrTags = useCallback(async (options?: { refreshMapped?: boolean }) => { const requestId = ++lanePrTagsRequestRef.current; - const startedRoot = appStore.getState().project?.rootPath ?? null; + const startedRoot = getActiveProjectRoot(); const stillCurrent = () => requestId === lanePrTagsRequestRef.current - && (appStore.getState().project?.rootPath ?? null) === startedRoot; + && getActiveProjectRoot() === startedRoot; try { const prs = await listPrsCoalesced({ projectRoot: startedRoot }); if (!stillCurrent()) return; @@ -956,25 +970,25 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { if (!stillCurrent()) return; setLanePrTags([]); } - }, [appStore]); + }, [getActiveProjectRoot]); const refreshLaneGithubPrTags = useCallback(async (options?: { force?: boolean }) => { const requestId = ++laneGithubPrTagsRequestRef.current; - const startedRoot = appStore.getState().project?.rootPath ?? null; + const startedRoot = getActiveProjectRoot(); try { const snapshot = await getGitHubSnapshotCoalesced( { force: options?.force === true }, { projectRoot: startedRoot }, ); if (requestId !== laneGithubPrTagsRequestRef.current) return; - if ((appStore.getState().project?.rootPath ?? null) !== startedRoot) return; + if (getActiveProjectRoot() !== startedRoot) return; setLaneGithubPrTags(snapshot.repoPullRequests); } catch { if (requestId !== laneGithubPrTagsRequestRef.current) return; - if ((appStore.getState().project?.rootPath ?? null) !== startedRoot) return; + if (getActiveProjectRoot() !== startedRoot) return; // Keep the last usable GitHub snapshot visible on transient refresh failures. } - }, [appStore]); + }, [getActiveProjectRoot]); const scheduleLaneDeleteRefresh = useCallback(() => { if (laneDeleteRefreshTimerRef.current != null) return; @@ -1117,27 +1131,27 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { void refreshIntegrationProposals(); }, 140); return () => window.clearTimeout(timer); - }, [active, refreshIntegrationProposals, project?.rootPath]); + }, [active, refreshIntegrationProposals, activeProjectRoot]); useEffect(() => { lanePrTagsRequestRef.current += 1; laneGithubPrTagsRequestRef.current += 1; setLanePrTags([]); setLaneGithubPrTags([]); - if (!active || !project?.rootPath) { + if (!active || !activeProjectRoot) { return; } void refreshLanePrTags({ refreshMapped: true }); void refreshLaneGithubPrTags({ force: true }); void warmPrSurfaceCoalesced({ - projectRoot: project.rootPath, + projectRoot: activeProjectRoot, includeGithubSnapshot: false, }); return () => { lanePrTagsRequestRef.current += 1; laneGithubPrTagsRequestRef.current += 1; }; - }, [active, refreshLanePrTags, refreshLaneGithubPrTags, project?.rootPath, lanePrBranchSignature]); + }, [active, refreshLanePrTags, refreshLaneGithubPrTags, activeProjectRoot, lanePrBranchSignature]); useEffect(() => { if (!active) return; @@ -1165,7 +1179,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { }, []); useEffect(() => { - const projectRoot = project?.rootPath ?? null; + const projectRoot = activeProjectRoot; if (laneVisiblePrRefreshProjectRootRef.current !== projectRoot) { laneVisiblePrRefreshProjectRootRef.current = projectRoot; laneVisiblePrRefreshRequestedAtRef.current.clear(); @@ -1190,7 +1204,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const timer = window.setTimeout(() => { void window.ade.prs.refresh({ prIds }) .then((refreshed) => { - if ((appStore.getState().project?.rootPath ?? null) !== startedRoot) return; + if (getActiveProjectRoot() !== startedRoot) return; if (refreshed.length === 0) return; setLanePrTags((current) => mergePrSummariesById(current, refreshed)); }) @@ -1202,8 +1216,8 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { return () => window.clearTimeout(timer); }, [ active, - appStore, - project?.rootPath, + getActiveProjectRoot, + activeProjectRoot, visibleLaneIds, lanePrByLaneId, lanePrTags, @@ -1239,7 +1253,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { void refreshRuntimeOnly().catch(() => {}); }, delayMs)); }; - const currentProjectRoot = project?.rootPath ?? null; + const currentProjectRoot = activeProjectRoot; const isCurrentProjectEvent = (event: { projectRoot?: string | null }) => !event.projectRoot || event.projectRoot === currentProjectRoot; const unsubPtyData = window.ade.pty.onData((event) => { @@ -1276,7 +1290,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { } window.clearInterval(intervalId); }; - }, [active, project?.rootPath, refreshLanes]); + }, [active, activeProjectRoot, refreshLanes]); useEffect(() => { hasActiveLaneRuntimeRef.current = laneSnapshots.some((snapshot) => @@ -1690,7 +1704,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { ]); useEffect(() => { - const projectRoot = project?.rootPath ?? null; + const projectRoot = laneProgressProjectRoot; if (!projectRoot) return; if (hydratedLaneDeleteProgressProjectRef.current === projectRoot) return; hydratedLaneDeleteProgressProjectRef.current = projectRoot; @@ -1756,7 +1770,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { return () => { cancelled = true; }; - }, [active, appStore, project?.rootPath, moveAwayFromDeletingLanes, queueLaneDeleteRefresh, setDeleteProgressByLaneId]); + }, [active, appStore, laneProgressProjectRoot, moveAwayFromDeletingLanes, queueLaneDeleteRefresh, setDeleteProgressByLaneId]); const deleteManagedLanes = async () => { const targets = isBatchManage ? managedLanes : managedLane ? [managedLane] : []; @@ -1895,7 +1909,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { setActiveLaneIds(mergeUnique([laneId], pinned)); selectLane(laneId); setStackGraphHeaderOpen(false); - setLaneWorkViewState(project?.rootPath ?? null, laneId, (prev) => ({ + setLaneWorkViewState(activeProjectRoot, laneId, (prev) => ({ ...prev, draftKind: "chat", viewMode: "tabs", @@ -1908,7 +1922,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { requestedAt: Date.now(), }); navigate(`/lanes?laneId=${encodeURIComponent(laneId)}`); - }, [deletingLaneIds, lanesById, navigate, pinnedLaneIds, project?.rootPath, selectLane, setLaneWorkViewState]); + }, [activeProjectRoot, deletingLaneIds, lanesById, navigate, pinnedLaneIds, selectLane, setLaneWorkViewState]); // Open a specific agent (chat or CLI) in the Work tab of its lane, from any // of the inline lane dashboards (stack drawer, graph card, lane list row). @@ -1919,7 +1933,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { setActiveLaneIds(mergeUnique([laneId], pinned)); selectLane(laneId); setStackGraphHeaderOpen(false); - setLaneWorkViewState(project?.rootPath ?? null, laneId, (prev) => ({ + setLaneWorkViewState(activeProjectRoot, laneId, (prev) => ({ ...prev, viewMode: "tabs", openItemIds: prev.openItemIds.includes(agent.sessionId) @@ -1929,7 +1943,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { selectedItemId: agent.sessionId, })); navigate(openAgentInWorkTabPath(laneId, agent.sessionId)); - }, [deletingLaneIds, lanesById, navigate, pinnedLaneIds, project?.rootPath, selectLane, setLaneWorkViewState]); + }, [activeProjectRoot, deletingLaneIds, lanesById, navigate, pinnedLaneIds, selectLane, setLaneWorkViewState]); const removeSplitLane = useCallback((laneId: string) => { if (pinnedLaneIds.has(laneId)) return; @@ -4420,7 +4434,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { // longer the canonical destination for opening a lane. navigate("/project"); }} - projectRoot={project?.rootPath ?? null} + projectRoot={activeProjectRoot} createBranches={createBranches} lanes={lanes} onSubmit={handleCreateSubmit} From 66741b2536b13256ed05ccedbb8670ed83682418 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 04:17:26 -0400 Subject: [PATCH 03/80] fix(remote): dedupe saved discovery targets --- .../remoteTargets/RemoteTargetList.test.tsx | 54 +++++++++++++++ .../remoteTargets/RemoteTargetList.tsx | 66 ++++++++++++++++++- 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx index 096ec9633..ceb34860c 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx @@ -289,6 +289,60 @@ describe("RemoteTargetList", () => { expect(screen.getByText("Connected")).toBeTruthy(); }); + it("hides discovered machines that are already saved as SSH targets", async () => { + const target = { + id: "target-1", + name: "Mac Studio", + hostname: "aruls-mac-studio.tail7497a6.ts.net", + sshUser: null, + port: null, + sshKeyPath: null, + routes: [ + { + hostname: "aruls-mac-studio.tail7497a6.ts.net", + port: null, + source: "tailscale", + lastSucceededAt: null, + }, + ], + lastSeenArch: "darwin-arm64", + runtimeBinaryVersion: "1.0.0", + lastConnectedAt: null, + }; + remoteRuntimeMock.listTargets.mockResolvedValue([target]); + remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ + machines: [ + { + id: "tailscale::mac-studio", + serviceName: "tailscale-ssh", + machineName: "Arul's Mac Studio", + hostIdentity: "mac-studio", + hostName: "aruls-mac-studio.tail7497a6.ts.net", + port: 22, + addresses: [], + primaryRoute: "aruls-mac-studio.tail7497a6.ts.net", + tailscaleAddress: "aruls-mac-studio.tail7497a6.ts.net", + runtimeKind: "tailscale-peer", + runtimeVersion: null, + projectIds: [], + projectCount: 0, + lastSeenAt: 1234, + }, + ], + diagnostics: [], + }); + installAdeMock(); + + render(); + + await waitFor(() => + expect(screen.getAllByText("Mac Studio").length).toBeGreaterThan(0), + ); + expect(screen.queryByText("Arul's Mac Studio")).toBeNull(); + expect(screen.queryByRole("button", { name: "Use host" })).toBeNull(); + expect(screen.getByText("Nearby machines are already saved above.")).toBeTruthy(); + }); + it("surfaces Tailscale discovery diagnostics separately from empty results", async () => { remoteRuntimeMock.listTargets.mockResolvedValue([]); remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index 6fa4b7451..4bebd684e 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -123,6 +123,20 @@ function isTailscaleRoute(hostname: string | null | undefined): boolean { return second >= 64 && second <= 127; } +function normalizeRouteHost(hostname: string | null | undefined): string { + return hostname?.trim().toLowerCase().replace(/\.$/, "") ?? ""; +} + +function normalizeRoutePort(port: number | null | undefined): number { + return port ?? 22; +} + +function routeIdentity(hostname: string | null | undefined, port: number | null | undefined): string | null { + const host = normalizeRouteHost(hostname); + if (!host) return null; + return `${host}:${normalizeRoutePort(port)}`; +} + function discoveredRouteSource( machine: RemoteRuntimeDiscoveredMachine, hostname: string, @@ -203,6 +217,43 @@ function targetConnectionLabel(target: RemoteRuntimeTarget): string { return `${userPrefix}${target.hostname}${portSuffix}${defaultHint}${fallbackHint}`; } +function targetRouteIdentities(target: RemoteRuntimeTarget): Set { + const identities = new Set(); + const primary = routeIdentity(target.hostname, target.port); + if (primary) identities.add(primary); + for (const route of target.routes ?? []) { + const identity = routeIdentity(route.hostname, route.port ?? target.port); + if (identity) identities.add(identity); + } + return identities; +} + +function discoveredMachineRouteIdentities( + machine: RemoteRuntimeDiscoveredMachine, +): Set { + const identities = new Set(); + for (const route of discoveredSshRoutes(machine)) { + const identity = routeIdentity(route.hostname, route.port ?? machine.port); + if (identity) identities.add(identity); + } + return identities; +} + +function discoveredMachineMatchesSavedTarget( + machine: RemoteRuntimeDiscoveredMachine, + targets: RemoteRuntimeTarget[], +): boolean { + const discovered = discoveredMachineRouteIdentities(machine); + if (discovered.size === 0) return false; + return targets.some((target) => { + const saved = targetRouteIdentities(target); + for (const identity of discovered) { + if (saved.has(identity)) return true; + } + return false; + }); +} + function connectionStateLabel( connection: RemoteRuntimeConnectionStatus | null, connected: RemoteRuntimeConnectResult | null, @@ -263,6 +314,13 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { selectedTarget && hostKeyTrust?.targetId === selectedTarget.id ? hostKeyTrust : null; + const visibleDiscoveredMachines = useMemo( + () => + discoveredMachines.filter( + (machine) => !discoveredMachineMatchesSavedTarget(machine, targets), + ), + [discoveredMachines, targets], + ); const loadTargets = useCallback(async () => { setLoading(true); @@ -741,7 +799,7 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { > Scanning nearby machines... - ) : discoveredMachines.length === 0 ? ( + ) : visibleDiscoveredMachines.length === 0 ? (
- No LAN ADE services or Tailscale peers found. + {discoveredMachines.length > 0 + ? "Nearby machines are already saved above." + : "No LAN ADE services or Tailscale peers found."}
) : (
- {discoveredMachines.map((machine) => { + {visibleDiscoveredMachines.map((machine) => { const route = discoveredRoute(machine); return (
Date: Thu, 4 Jun 2026 06:12:50 -0400 Subject: [PATCH 04/80] fix(remote): harden runtime reconnect and uploads --- .../main/services/ipc/runtimeBridge.test.ts | 2 +- .../src/main/services/ipc/runtimeBridge.ts | 17 +- .../main/services/lanes/laneService.test.ts | 9 +- .../src/main/services/lanes/laneService.ts | 2 +- .../remoteRuntime/remoteBootstrap.test.ts | 173 ++++++- .../services/remoteRuntime/remoteBootstrap.ts | 427 ++++++++++++++---- .../remoteConnectionPool.test.ts | 50 ++ .../remoteRuntime/remoteConnectionPool.ts | 122 ++++- .../remoteRuntime/remoteConnectionService.ts | 60 +++ .../remoteRuntime/sshTransport.test.ts | 2 +- .../services/remoteRuntime/sshTransport.ts | 15 +- apps/desktop/src/preload/preload.test.ts | 65 +++ apps/desktop/src/preload/preload.ts | 38 +- .../renderer/components/lanes/LanesPage.tsx | 4 +- 14 files changed, 833 insertions(+), 153 deletions(-) diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 2f67efc01..e670c53ff 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -520,7 +520,7 @@ describe("registerRuntimeBridge", () => { ), ).resolves.toMatchObject({ result: { ptyId: "pty-1" } }); - expect(remoteConnectMock).toHaveBeenCalledWith(target); + expect(remoteConnectMock).not.toHaveBeenCalled(); expect(remoteCallActionForTargetMock).toHaveBeenCalledWith( target, "project-1", diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 96395a61f..6e621427f 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -727,13 +727,12 @@ export function registerRuntimeBridge({ if (!projectId) throw new Error("Remote project is required."); if (!domain || !action) throw new Error("Remote action domain and action are required."); - await remoteConnectionService.connect(target.id); const actionRequest = withRuntimeActionClientMetadata( { ...request!, domain, action }, event.sender.id, ); - return await remoteConnectionPool.callActionForTarget( - target, + return await remoteConnectionService.callAction( + target.id, projectId, actionRequest, ); @@ -947,7 +946,11 @@ export function registerRuntimeBridge({ if (!projectId) throw new Error("Remote project id is required."); const target = remoteConnectionService.getTarget(id); if (!target) throw new Error("Remote target was not found."); - await remoteConnectionService.connect(target.id); + const result = await remoteConnectionService.streamEvents( + target.id, + projectId, + arg?.request ?? {}, + ); ensureRuntimeEventSubscription( event.sender, `remote:${target.id}:${projectId}`, @@ -965,11 +968,7 @@ export function registerRuntimeBridge({ onEnded, ), ); - return remoteConnectionPool.streamEventsForTarget( - target, - projectId, - arg?.request ?? {}, - ); + return result; }, ); diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index aa176bb8c..b362ce84f 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3059,7 +3059,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(wtStep?.detail).toContain("manual cleanup failed"); }); - it("keeps recent delete progress queryable for remounted renderers", async () => { + it("keeps only running delete progress queryable for remounted renderers", async () => { const events: any[] = []; const fake = makeFakeServices(); const { service } = await setupWithLane({ teardown: fake, events }); @@ -3088,9 +3088,10 @@ describe("laneService delete teardown + cancellation + streaming", () => { await deletePromise; const completedProgress = service.listDeleteProgress(); - expect(completedProgress).toHaveLength(1); - expect(completedProgress[0]?.laneId).toBe("lane-child"); - expect(completedProgress[0]?.overallStatus).toBe("completed"); + expect(completedProgress).toHaveLength(0); + const last = events[events.length - 1]; + expect(last.progress.laneId).toBe("lane-child"); + expect(last.progress.overallStatus).toBe("completed"); }); it("reports whether a lane delete is currently running", async () => { diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 3d92aa795..19fd8f65a 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -4553,7 +4553,7 @@ export function createLaneService({ listDeleteProgress(): LaneDeleteProgress[] { pruneDeleteProgressHistory(); return Array.from(deleteProgressByLaneId.values()) - .filter((progress) => progress.overallStatus === "running" || progress.overallStatus === "completed" || progress.overallStatus === "completed_with_warnings") + .filter((progress) => progress.overallStatus === "running") .map(cloneLaneDeleteProgress); }, diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index d6fce7be6..8c2759cbb 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -23,10 +23,15 @@ import { const connectSshWithRouteMock = vi.hoisted(() => vi.fn()); const execSshMock = vi.hoisted(() => vi.fn()); const openSshRuntimeTransportMock = vi.hoisted(() => vi.fn()); +const spawnMock = vi.hoisted(() => vi.fn()); const initializeMock = vi.hoisted(() => vi.fn()); const callMock = vi.hoisted(() => vi.fn()); const runtimeRpcClientMock = vi.hoisted(() => vi.fn()); +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + vi.mock("./sshTransport", () => ({ connectSshWithRoute: connectSshWithRouteMock, execSsh: execSshMock, @@ -87,6 +92,13 @@ describe("selectRemoteRuntimeVersion", () => { executableVersion: null, })).toBe("1.0.0"); }); + + it("uses the marker when the executable reports the placeholder version", () => { + expect(selectRemoteRuntimeVersion({ + markerVersion: "1.0.0", + executableVersion: "0.0.0", + })).toBe("1.0.0"); + }); }); describe("shouldUploadBundledRuntime", () => { @@ -108,6 +120,18 @@ describe("shouldUploadBundledRuntime", () => { })).toBe(false); }); + it("skips upload when a placeholder executable has a matching marker and binary identity", () => { + expect(shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: "0.0.0", + markerVersion: "1.0.0", + appVersion: "1.0.0", + localBinarySha256: "abc", + remoteBinarySha256: "abc", + remoteBinaryMatchesLocal: true, + })).toBe(false); + }); + it("uploads when the executable version matches but the binary hash changed", () => { expect(shouldUploadBundledRuntime({ localBinaryAvailable: true, @@ -118,6 +142,17 @@ describe("shouldUploadBundledRuntime", () => { })).toBe(true); }); + it("uploads when marker files match but the actual remote binary does not", () => { + expect(shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: "1.0.0", + appVersion: "1.0.0", + localBinarySha256: "abc", + remoteBinarySha256: "abc", + remoteBinaryMatchesLocal: false, + })).toBe(true); + }); + it("does not upload when no bundled runtime exists for the remote architecture", () => { expect(shouldUploadBundledRuntime({ localBinaryAvailable: false, @@ -281,6 +316,34 @@ function ok(stdout = "") { return { stdout, stderr: "", code: 0 }; } +function createFakeSpawnProcess(options: { closeCode?: number; error?: Error; stderr?: string } = {}) { + const child = new EventEmitter() as EventEmitter & { + stdin: EventEmitter & { + write: ReturnType; + end: ReturnType; + destroy: ReturnType; + }; + stderr: EventEmitter; + kill: ReturnType; + }; + child.stderr = new EventEmitter(); + child.stdin = Object.assign(new EventEmitter(), { + write: vi.fn(() => true), + end: vi.fn(), + destroy: vi.fn(), + }); + child.kill = vi.fn(); + setImmediate(() => { + if (options.error) { + child.emit("error", options.error); + return; + } + if (options.stderr) child.stderr.emit("data", Buffer.from(options.stderr)); + child.emit("close", options.closeCode ?? 0, null); + }); + return child; +} + function createTempResources( archLabel = "linux-x64", options: { nativeDeps?: boolean } = {}, @@ -302,15 +365,24 @@ function createTempResources( }; } -function createFakeSsh() { +function createFakeSsh(options: { execError?: Error; channelError?: Error; closeCode?: number; stderr?: string } = {}) { const exec = vi.fn((command: string, callback: (error: Error | null, channel: PassThrough & { stderr: PassThrough }) => void) => { const channel = new PassThrough() as PassThrough & { stderr: PassThrough }; channel.stderr = new PassThrough(); channel.resume(); + if (options.execError) { + setImmediate(() => callback(options.execError!, channel)); + return; + } channel.on("finish", () => { setImmediate(() => { - channel.emit("exit", 0, null); - channel.emit("close", 0, null); + if (options.channelError) { + channel.emit("error", options.channelError); + return; + } + if (options.stderr) channel.stderr.emit("data", Buffer.from(options.stderr)); + channel.emit("exit", options.closeCode ?? 0, null); + channel.emit("close", options.closeCode ?? 0, null); }); }); callback(null, channel); @@ -338,6 +410,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { connectSshWithRouteMock.mockReset(); execSshMock.mockReset(); openSshRuntimeTransportMock.mockReset(); + spawnMock.mockReset(); initializeMock.mockReset(); callMock.mockReset(); runtimeRpcClientMock.mockReset(); @@ -355,6 +428,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { write: vi.fn(), close: vi.fn(), }); + spawnMock.mockImplementation(() => createFakeSpawnProcess()); initializeMock.mockResolvedValue({ runtimeInfo: { version: APP_VERSION, multiProject: true }, capabilities: { @@ -396,9 +470,23 @@ describe("bootstrapRemoteRuntime upload flow", () => { cleanupResources = resources.cleanup; const fakeSsh = createFakeSsh(); const registry = createRegistry(); + const targetFromSshConfig: RemoteRuntimeTarget = { + ...uploadTarget, + sshUser: null, + port: null, + }; + const connectedRoute = { + ...uploadRoute, + port: null, + }; connectSshWithRouteMock.mockResolvedValue({ client: fakeSsh.ssh, - route: uploadRoute, + route: connectedRoute, + config: { + host: "resolved-build-host.local", + port: 2200, + username: "admin", + }, }); const commands: string[] = []; execSshMock.mockImplementation(async (_client: Client, command: string) => { @@ -408,6 +496,12 @@ describe("bootstrapRemoteRuntime upload flow", () => { if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade\/bin\/ade\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.binaryPath).size}\n`); if ( command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && command.includes("shasum -a 256 $HOME/.ade/bin/ade.upload-") && @@ -420,17 +514,18 @@ describe("bootstrapRemoteRuntime upload flow", () => { }); const connected = await bootstrapRemoteRuntime({ - target: uploadTarget, + target: targetFromSshConfig, registry, resourcesPath: resources.resourcesPath, appVersion: APP_VERSION, }); - expect(connectSshWithRouteMock).toHaveBeenCalledWith(uploadTarget); + expect(connectSshWithRouteMock).toHaveBeenCalledWith(targetFromSshConfig); expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(/^umask 077; cat > \$HOME\/\.ade\/bin\/ade\.upload-.*\.tmp$/), + expect.stringMatching(/^umask 077; cat >> \$HOME\/\.ade\/bin\/ade\.upload-.*\.tmp$/), expect.any(Function), ); + expect(spawnMock).not.toHaveBeenCalled(); expect(commands.slice(0, 4)).toEqual([ "uname -sm", "cat $HOME/.ade/bin/ade.version 2>/dev/null || true", @@ -462,7 +557,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { routes: [ { hostname: "build-host.local", - port: 22, + port: null, source: "manual", lastSucceededAt: expect.any(Number), }, @@ -491,6 +586,12 @@ describe("bootstrapRemoteRuntime upload flow", () => { if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade\/bin\/ade\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.binaryPath).size}\n`); if ( command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && command.includes("mv -f $HOME/.ade/bin/ade.upload-") && @@ -508,28 +609,21 @@ describe("bootstrapRemoteRuntime upload flow", () => { })).rejects.toThrow(/uploaded ade service version mismatch/i); expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(/^umask 077; cat > \$HOME\/\.ade\/bin\/ade\.upload-.*\.tmp$/), + expect.stringMatching(/^umask 077; cat >> \$HOME\/\.ade\/bin\/ade\.upload-.*\.tmp$/), expect.any(Function), ); + expect(spawnMock).not.toHaveBeenCalled(); expect(openSshRuntimeTransportMock).not.toHaveBeenCalled(); expect(initializeMock).not.toHaveBeenCalled(); expect(registry.update).not.toHaveBeenCalled(); expect(fakeSsh.end).toHaveBeenCalledTimes(1); }); - it("fails closed when SSH disconnects during runtime upload", async () => { + it("falls back to OpenSSH only when the connected SSH upload fails before writing", async () => { const resources = createTempResources(); cleanupResources = resources.cleanup; - const fakeSsh = createFakeSsh(); - fakeSsh.exec.mockImplementation((_command: string, callback: (error: Error | null, channel: PassThrough & { stderr: PassThrough }) => void) => { - const channel = new PassThrough() as PassThrough & { stderr: PassThrough }; - channel.stderr = new PassThrough(); - channel.resume(); - callback(null, channel); - setImmediate(() => { - (fakeSsh.ssh as unknown as EventEmitter).emit("close"); - }); - }); + const fakeSsh = createFakeSsh({ execError: new Error("channel denied") }); + spawnMock.mockImplementation(() => createFakeSpawnProcess({ error: new Error("pipe broke") })); const registry = createRegistry(); connectSshWithRouteMock.mockResolvedValue({ client: fakeSsh.ssh, @@ -543,6 +637,12 @@ describe("bootstrapRemoteRuntime upload flow", () => { if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade\/bin\/ade\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok("0\n"); if (command.startsWith("rm -f $HOME/.ade/bin/ade.upload-")) return ok(""); throw new Error(`Unexpected SSH command: ${command}`); }); @@ -552,8 +652,17 @@ describe("bootstrapRemoteRuntime upload flow", () => { registry, resourcesPath: resources.resourcesPath, appVersion: APP_VERSION, - })).rejects.toThrow(/ssh connection closed while uploading ade service artifact/i); + })).rejects.toThrow(/existing SSH session: SSH upload channel failed.*channel denied.*OpenSSH fallback failed: SSH upload process failed.*pipe broke/i); + expect(fakeSsh.exec).toHaveBeenCalledWith( + expect.stringMatching(/^umask 077; cat >> \$HOME\/\.ade\/bin\/ade\.upload-.*\.tmp$/), + expect.any(Function), + ); + expect(spawnMock).toHaveBeenCalledWith( + "ssh", + expect.arrayContaining(["-p", "22", "ade@build-host.local", expect.stringMatching(/^umask 077; cat >> \$HOME\/\.ade\/bin\/ade\.upload-.*\.tmp$/)]), + expect.objectContaining({ stdio: [expect.any(Number), "ignore", "pipe"] }), + ); expect(commands.some((command) => command.startsWith("rm -f $HOME/.ade/bin/ade.upload-"))).toBe(true); expect(openSshRuntimeTransportMock).not.toHaveBeenCalled(); expect(initializeMock).not.toHaveBeenCalled(); @@ -578,6 +687,18 @@ describe("bootstrapRemoteRuntime upload flow", () => { if (command === "test -x $HOME/.ade-alpha/bin/ade && $HOME/.ade-alpha/bin/ade --version || true") return ok(""); if (command === "mkdir -p $HOME/.ade-alpha/bin && chmod 700 $HOME/.ade-alpha/bin") return ok(""); if (command === "mkdir -p $HOME/.ade-alpha/runtime") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade-alpha\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade-alpha\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade-alpha\/bin\/ade\.upload-/)) return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade-alpha\/runtime\/ade-darwin-arm64\.native\.tar\.gz\.upload-.* && umask 077 && : > \$HOME\/\.ade-alpha\/runtime\/ade-darwin-arm64\.native\.tar\.gz\.upload-.* && chmod 600 \$HOME\/\.ade-alpha\/runtime\/ade-darwin-arm64\.native\.tar\.gz\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade-alpha/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.binaryPath).size}\n`); + if ( + command.includes("wc -c < $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(path.join(resources.resourcesPath, "runtime", "ade-darwin-arm64.native.tar.gz")).size}\n`); if ( command.includes("wc -c < $HOME/.ade-alpha/bin/ade.upload-") && command.includes("mv -f $HOME/.ade-alpha/bin/ade.upload-") && @@ -603,13 +724,14 @@ describe("bootstrapRemoteRuntime upload flow", () => { }); expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(/^umask 077; cat > \$HOME\/\.ade-alpha\/bin\/ade\.upload-.*\.tmp$/), + expect.stringMatching(/^umask 077; cat >> \$HOME\/\.ade-alpha\/bin\/ade\.upload-.*\.tmp$/), expect.any(Function), ); expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(/^umask 077; cat > \$HOME\/\.ade-alpha\/runtime\/ade-darwin-arm64\.native\.tar\.gz\.upload-.*\.tmp$/), + expect.stringMatching(/^umask 077; cat >> \$HOME\/\.ade-alpha\/runtime\/ade-darwin-arm64\.native\.tar\.gz\.upload-.*\.tmp$/), expect.any(Function), ); + expect(spawnMock).not.toHaveBeenCalled(); expect(execSshMock).toHaveBeenCalledWith(fakeSsh.ssh, "codesign --force --sign - $HOME/.ade-alpha/bin/ade"); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, @@ -808,6 +930,11 @@ describe("bootstrapRemoteRuntime upload flow", () => { if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok("2.0.0\n"); if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(`${resources.binarySha256}\n`); if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok("ade 2.0.0\n"); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade") && + command.includes("echo ok") + ) return ok("ok\n"); if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); throw new Error(`Unexpected SSH command: ${command}`); }); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index f5592f77f..15b25f615 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -1,7 +1,9 @@ import crypto from "node:crypto"; +import { spawn } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import type { Client } from "ssh2"; +import type { Client, ConnectConfig } from "ssh2"; import type { RemoteRuntimeCapabilities, RemoteRuntimeConnectResult, @@ -11,7 +13,7 @@ import type { RemoteRuntimeTargetRoute, } from "../../../shared/types/remoteRuntime"; import { RuntimeRpcClient } from "./runtimeRpcClient"; -import { connectSshWithRoute, execSsh, openSshRuntimeTransport } from "./sshTransport"; +import { connectSshWithRoute, execSsh, openSshRuntimeTransport, type ConnectedSshRoute } from "./sshTransport"; import { routeKey } from "./routeUtils"; import { normalizeRemoteTargetRoutes, @@ -41,22 +43,36 @@ export function normalizeRuntimeVersion(raw: string): string | null { return version || null; } +const PLACEHOLDER_RUNTIME_VERSION = "0.0.0"; + export function selectRemoteRuntimeVersion(args: { markerVersion: string | null; executableVersion: string | null; }): string | null { - return args.executableVersion ?? args.markerVersion; + if (args.executableVersion && args.executableVersion !== PLACEHOLDER_RUNTIME_VERSION) { + return args.executableVersion; + } + return args.markerVersion ?? args.executableVersion; } export function shouldUploadBundledRuntime(args: { localBinaryAvailable: boolean; executableVersion: string | null; + markerVersion?: string | null; appVersion: string; localBinarySha256?: string | null; remoteBinarySha256?: string | null; + remoteBinaryMatchesLocal?: boolean | null; }): boolean { if (!args.localBinaryAvailable) return false; - if (args.executableVersion !== args.appVersion) return true; + const installedVersion = selectRemoteRuntimeVersion({ + markerVersion: args.markerVersion ?? null, + executableVersion: args.executableVersion, + }); + if (installedVersion !== args.appVersion) return true; + if (args.remoteBinaryMatchesLocal != null) { + return !args.remoteBinaryMatchesLocal; + } if (args.localBinarySha256) { return args.remoteBinarySha256 !== args.localBinarySha256; } @@ -293,26 +309,92 @@ function remoteFileMatchesCommand(fileExpr: string, expectedSize: number, expect } const REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS = 10 * 60_000; -const REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS = 120_000; +const REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS = 45_000; +const REMOTE_ARTIFACT_UPLOAD_CHUNK_BYTES = 1024 * 1024; +const REMOTE_ARTIFACT_UPLOAD_NO_PROGRESS_RETRIES = 2; + +function openSshArgsForRoute( + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: Pick | null | undefined, + remoteCommand: string, +): string[] { + const args = [ + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + ]; + const identityFile = target.sshKeyPath?.trim(); + if (identityFile) args.push("-i", identityFile); + const port = connectedConfig?.port ?? route.port ?? target.port; + if (port) args.push("-p", String(port)); + const user = connectedConfig?.username?.trim() || target.sshUser?.trim(); + const host = connectedConfig?.host?.trim() || route.hostname; + args.push(user ? `${user}@${host}` : host); + args.push(remoteCommand); + return args; +} -async function uploadSshFile(client: Client, localPath: string, remoteFileExpr: string): Promise { +async function writeTemporaryUploadChunk(chunk: Buffer): Promise<{ path: string; remove: () => Promise }> { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "ade-remote-upload-")); + const filePath = path.join(dir, "chunk.bin"); + await fs.promises.writeFile(filePath, chunk, { mode: 0o600 }); + return { + path: filePath, + remove: () => fs.promises.rm(dir, { recursive: true, force: true }), + }; +} + +async function uploadSshChunk( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: Pick | null | undefined, + remoteFileExpr: string, + chunk: Buffer, + committedBytes: number, +): Promise { + try { + await uploadSshChunkViaConnectedClient(client, remoteFileExpr, chunk, committedBytes); + return; + } catch (error) { + if ((error as { wroteToChannel?: boolean }).wroteToChannel) { + throw error; + } + try { + await uploadSshChunkViaOpenSsh(target, route, connectedConfig, remoteFileExpr, chunk, committedBytes); + } catch (fallbackError) { + const primaryMessage = error instanceof Error ? error.message : String(error); + const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + throw new Error(`Unable to upload ADE service artifact over the existing SSH session: ${primaryMessage}; OpenSSH fallback failed: ${fallbackMessage}`); + } + } +} + +async function uploadSshChunkViaConnectedClient( + client: Client, + remoteFileExpr: string, + chunk: Buffer, + committedBytes: number, +): Promise { await new Promise((resolve, reject) => { let settled = false; - let readStream: fs.ReadStream | null = null; - let uploadChannel: (NodeJS.WritableStream & { - stderr?: NodeJS.ReadableStream; - close?: () => void; - destroy?: (error?: Error) => void; - }) | null = null; + let wroteToChannel = false; + let stderr = ""; let exitCode: number | null = null; let exitSignal: string | null = null; - let stderr = ""; - let uploadedBytes = 0; let timeout: NodeJS.Timeout | null = null; let idleTimeout: NodeJS.Timeout | null = null; + let channelRef: { close?: () => void; destroy?: () => void } | null = null; const uploadProgressSuffix = (): string => - uploadedBytes > 0 ? ` after ${uploadedBytes} bytes` : ""; + committedBytes > 0 ? ` after ${committedBytes} committed bytes` : ""; + + const markError = (error: Error): Error => { + (error as Error & { wroteToChannel?: boolean }).wroteToChannel = wroteToChannel; + return error; + }; const clearTimers = (): void => { if (timeout) { @@ -325,54 +407,34 @@ async function uploadSshFile(client: Client, localPath: string, remoteFileExpr: } }; - const resetIdleTimer = (): void => { - if (idleTimeout) clearTimeout(idleTimeout); - idleTimeout = setTimeout(() => { - settle(new Error(`Timed out uploading ADE service artifact: no read or drain progress for ${REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); - }, REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS); - idleTimeout.unref?.(); - }; - - const onClientClose = (): void => { - settle(new Error(`SSH connection closed while uploading ADE service artifact${uploadProgressSuffix()}.`)); - }; - const onClientEnd = (): void => { - settle(new Error(`SSH connection ended while uploading ADE service artifact${uploadProgressSuffix()}.`)); - }; - const onClientError = (error: Error): void => { - settle(new Error(`SSH connection failed while uploading ADE service artifact${uploadProgressSuffix()}: ${error.message}`)); - }; - - const removeClientListeners = (): void => { - client.off("close", onClientClose); - client.off("end", onClientEnd); - client.off("error", onClientError); + const closeChannel = (): void => { + try { + channelRef?.close?.(); + } catch {} + try { + channelRef?.destroy?.(); + } catch {} }; const settle = (error?: Error | null): void => { if (settled) return; settled = true; clearTimers(); - removeClientListeners(); if (error) { - try { - readStream?.destroy(); - } catch {} - try { - uploadChannel?.close?.(); - } catch {} - try { - uploadChannel?.destroy?.(error); - } catch {} - reject(error); + closeChannel(); + reject(markError(error)); return; } resolve(); }; - client.once("close", onClientClose); - client.once("end", onClientEnd); - client.once("error", onClientError); + const resetIdleTimer = (): void => { + if (idleTimeout) clearTimeout(idleTimeout); + idleTimeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact: no remote chunk completion for ${REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS); + idleTimeout.unref?.(); + }; timeout = setTimeout(() => { settle(new Error(`Timed out uploading ADE service artifact after ${REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); @@ -380,57 +442,218 @@ async function uploadSshFile(client: Client, localPath: string, remoteFileExpr: timeout.unref?.(); resetIdleTimer(); - client.exec(`umask 077; cat > ${remoteFileExpr}`, (error, channel) => { + client.exec(`umask 077; cat >> ${remoteFileExpr}`, (error, channel) => { if (error) { - settle(error); + settle(new Error(`SSH upload channel failed${uploadProgressSuffix()}: ${error.message}`)); return; } - uploadChannel = channel as typeof uploadChannel; - readStream = fs.createReadStream(localPath); - channel.stderr.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); + channelRef = channel as unknown as { close?: () => void; destroy?: () => void }; + channel.resume(); + channel.stderr?.on("data", (data: Buffer | string) => { + stderr += data.toString(); }); channel.on("exit", (code: number | null, signal: string | null) => { - exitCode = code; - exitSignal = signal; + exitCode = typeof code === "number" ? code : null; + exitSignal = signal ?? null; }); - channel.on("error", (streamError: Error) => { - settle(streamError); + channel.on("error", (channelError: Error) => { + settle(new Error(`SSH upload channel failed${uploadProgressSuffix()}: ${channelError.message}`)); }); - channel.on("drain", () => { - resetIdleTimer(); + channel.on("close", () => { + if (exitCode && exitCode !== 0) { + const detail = stderr.trim() || `remote command exited with code ${exitCode}`; + settle(new Error(`Unable to upload ADE service artifact: ${detail}`)); + return; + } + if (exitSignal) { + const detail = stderr.trim() || `remote command exited with signal ${exitSignal}`; + settle(new Error(`Unable to upload ADE service artifact: ${detail}`)); + return; + } + settle(null); }); - readStream.on("data", (chunk: Buffer | string) => { - uploadedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); - resetIdleTimer(); + const finishWrite = (): void => { + try { + channel.end(); + } catch (writeError) { + settle(new Error(`SSH upload channel failed${uploadProgressSuffix()}: ${writeError instanceof Error ? writeError.message : String(writeError)}`)); + } + }; + wroteToChannel = true; + if (!channel.write(chunk)) { + channel.once("drain", finishWrite); + } else { + finishWrite(); + } + }); + }); +} + +async function uploadSshChunkViaOpenSsh( + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: Pick | null | undefined, + remoteFileExpr: string, + chunk: Buffer, + committedBytes: number, +): Promise { + const chunkFile = await writeTemporaryUploadChunk(chunk); + const chunkHandle = await fs.promises.open(chunkFile.path, "r"); + try { + await new Promise((resolve, reject) => { + let settled = false; + const child = spawn("ssh", openSshArgsForRoute(target, route, connectedConfig, `umask 077; cat >> ${remoteFileExpr}`), { + stdio: [chunkHandle.fd, "ignore", "pipe"], }); - readStream.on("end", () => { - resetIdleTimer(); + let stderr = ""; + let timeout: NodeJS.Timeout | null = null; + let idleTimeout: NodeJS.Timeout | null = null; + + const uploadProgressSuffix = (): string => + committedBytes > 0 ? ` after ${committedBytes} committed bytes` : ""; + + const clearTimers = (): void => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (idleTimeout) { + clearTimeout(idleTimeout); + idleTimeout = null; + } + }; + + const resetIdleTimer = (): void => { + if (idleTimeout) clearTimeout(idleTimeout); + idleTimeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact: no remote chunk completion for ${REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS); + idleTimeout.unref?.(); + }; + + const settle = (error?: Error | null): void => { + if (settled) return; + settled = true; + clearTimers(); + if (error) { + try { child.kill("SIGKILL"); } catch {} + reject(error); + return; + } + resolve(); + }; + + timeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact after ${REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS); + timeout.unref?.(); + resetIdleTimer(); + + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); }); - readStream.on("error", (streamError) => { - settle(streamError); + child.on("error", (error) => { + settle(new Error(`SSH upload process failed${uploadProgressSuffix()}: ${error.message}`)); }); - channel.on("close", (code: number | null, signal: string | null) => { - const resolvedCode = code ?? exitCode; - const resolvedSignal = signal ?? exitSignal; - if (resolvedCode && resolvedCode !== 0) { - const detail = stderr.trim() || `remote command exited with code ${resolvedCode}`; + child.on("close", (code, signal) => { + if (code && code !== 0) { + const detail = stderr.trim() || `ssh exited with code ${code}`; settle(new Error(`Unable to upload ADE service artifact: ${detail}`)); return; } - if (resolvedSignal) { - const detail = stderr.trim() || `remote command exited with signal ${resolvedSignal}`; + if (signal) { + const detail = stderr.trim() || `ssh exited with signal ${signal}`; settle(new Error(`Unable to upload ADE service artifact: ${detail}`)); return; } settle(null); }); - readStream.pipe(channel); }); - }); + } finally { + await chunkHandle.close().catch(() => okIgnored()); + await chunkFile.remove(); + } } -async function uploadRuntimeBinary(client: Client, layout: RemoteRuntimeLayout, localPath: string, appVersion: string, localBinarySha256: string): Promise { +async function uploadSshFile( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: Pick | null | undefined, + localPath: string, + remoteFileExpr: string, +): Promise { + const totalBytes = fileSizeBytes(localPath); + await execSshOrThrow( + client, + `rm -f ${remoteFileExpr} && umask 077 && : > ${remoteFileExpr} && chmod 600 ${remoteFileExpr}`, + "Unable to prepare remote ADE service artifact upload.", + ); + const readRemoteBytes = async (): Promise => { + const result = await execSsh(client, `wc -c < ${remoteFileExpr} | tr -d '[:space:]'`); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || "Unable to read remote ADE service artifact upload size."); + } + const value = Number.parseInt(result.stdout.trim(), 10); + if (!Number.isFinite(value) || value < 0 || value > totalBytes) { + throw new Error(`Remote ADE service artifact upload reported invalid size ${result.stdout.trim() || ""}.`); + } + return value; + }; + const file = await fs.promises.open(localPath, "r"); + try { + const buffer = Buffer.allocUnsafe(REMOTE_ARTIFACT_UPLOAD_CHUNK_BYTES); + let committedBytes = 0; + let noProgressRetries = 0; + while (committedBytes < totalBytes) { + const targetBytes = Math.min(REMOTE_ARTIFACT_UPLOAD_CHUNK_BYTES, totalBytes - committedBytes); + const { bytesRead } = await file.read(buffer, 0, targetBytes, committedBytes); + if (bytesRead <= 0) { + throw new Error(`Unable to read ADE service artifact after ${committedBytes} bytes.`); + } + try { + await uploadSshChunk(client, target, route, connectedConfig, remoteFileExpr, buffer.subarray(0, bytesRead), committedBytes); + } catch (error) { + const remoteBytes = await readRemoteBytes(); + if (remoteBytes > committedBytes) { + committedBytes = remoteBytes; + noProgressRetries = 0; + continue; + } + noProgressRetries += 1; + if (noProgressRetries <= REMOTE_ARTIFACT_UPLOAD_NO_PROGRESS_RETRIES) { + continue; + } + throw error; + } + const remoteBytes = committedBytes + bytesRead; + const actualRemoteBytes = await readRemoteBytes(); + if (actualRemoteBytes !== remoteBytes) { + if (actualRemoteBytes > committedBytes) { + committedBytes = actualRemoteBytes; + noProgressRetries = 0; + continue; + } + throw new Error(`Uploaded ADE service artifact stopped at ${actualRemoteBytes} bytes.`); + } + committedBytes = remoteBytes; + noProgressRetries = 0; + } + } finally { + await file.close(); + } +} + +async function uploadRuntimeBinary( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: Pick | null | undefined, + layout: RemoteRuntimeLayout, + localPath: string, + appVersion: string, + localBinarySha256: string, +): Promise { const tempSuffix = remoteUploadTempSuffix(); const tempExpr = `${layout.binaryExpr}.${tempSuffix}`; await execSshOrThrow( @@ -439,7 +662,7 @@ async function uploadRuntimeBinary(client: Client, layout: RemoteRuntimeLayout, "Unable to create the remote ADE service directory.", ); try { - await uploadSshFile(client, localPath, tempExpr); + await uploadSshFile(client, target, route, connectedConfig, localPath, tempExpr); await execSshOrThrow(client, [ remoteFileMatchesCommand(tempExpr, fileSizeBytes(localPath), localBinarySha256), `chmod +x ${tempExpr}`, @@ -457,7 +680,16 @@ async function uploadRuntimeBinary(client: Client, layout: RemoteRuntimeLayout, function okIgnored(): void {} -async function uploadNativeDepsBundle(client: Client, layout: RemoteRuntimeLayout, archLabel: string, localPath: string, appVersion: string): Promise { +async function uploadNativeDepsBundle( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: Pick | null | undefined, + layout: RemoteRuntimeLayout, + archLabel: string, + localPath: string, + appVersion: string, +): Promise { const localSha256 = hashRuntimeBinary(localPath); const remoteArchiveExpr = `${layout.runtimeDirExpr}/ade-${archLabel}.native.tar.gz`; const tempSuffix = remoteUploadTempSuffix(); @@ -468,7 +700,7 @@ async function uploadNativeDepsBundle(client: Client, layout: RemoteRuntimeLayou "Unable to create the remote ADE native dependency directory.", ); try { - await uploadSshFile(client, localPath, tempExpr); + await uploadSshFile(client, target, route, connectedConfig, localPath, tempExpr); const extract = await execSsh(client, [ remoteFileMatchesCommand(tempExpr, fileSizeBytes(localPath), localSha256), `mv -f ${tempExpr} ${remoteArchiveExpr}`, @@ -591,7 +823,7 @@ export async function bootstrapRemoteRuntime(args: { resourcesPath: string; appVersion: string; }): Promise<{ client: RuntimeRpcClient; result: RemoteRuntimeConnectResult; ssh: Client }> { - const { client: ssh, route: connectedRoute } = await connectSshWithRoute(args.target); + const { client: ssh, route: connectedRoute, config: connectedConfig } = await connectSshWithRoute(args.target); try { const uname = await execSsh(ssh, "uname -sm"); if (uname.code !== 0) { @@ -614,6 +846,7 @@ export async function bootstrapRemoteRuntime(args: { const localBinary = bundledRuntimePath(args.resourcesPath, arch.label); const localBinarySha256 = localBinary ? hashRuntimeBinary(localBinary) : null; const nativeDepsBundle = bundledNativeDepsPath(args.resourcesPath, arch.label); + let remoteBinaryMatchesLocal: boolean | null = null; if (!localBinary && !executableRuntimeVersion) { for (const candidateLayout of resolveRemoteRuntimeLayoutCandidates().filter((candidate) => candidate.homeDirName !== layout.homeDirName)) { @@ -639,14 +872,36 @@ export async function bootstrapRemoteRuntime(args: { } let runtimeUploaded = false; + if (localBinary && localBinarySha256 && runtimeVersion === args.appVersion) { + if (arch.platform === "darwin") { + if (remoteBinarySha256 === localBinarySha256) { + const binarySignatureCheck = await execSsh( + ssh, + `codesign --verify ${layout.binaryExpr} >/dev/null 2>&1 && echo ok || true`, + ); + remoteBinaryMatchesLocal = binarySignatureCheck.stdout.trim() === "ok"; + } else { + remoteBinaryMatchesLocal = false; + } + } else { + const binaryContentCheck = await execSsh( + ssh, + `${remoteFileMatchesCommand(layout.binaryExpr, fileSizeBytes(localBinary), localBinarySha256)} && echo ok || true`, + ); + remoteBinaryMatchesLocal = binaryContentCheck.stdout.trim() === "ok"; + } + } + if (localBinary && localBinarySha256 && shouldUploadBundledRuntime({ localBinaryAvailable: true, executableVersion: executableRuntimeVersion, + markerVersion: markedRuntimeVersion, appVersion: args.appVersion, localBinarySha256, remoteBinarySha256, + remoteBinaryMatchesLocal, })) { - await uploadRuntimeBinary(ssh, layout, localBinary, args.appVersion, localBinarySha256); + await uploadRuntimeBinary(ssh, args.target, connectedRoute, connectedConfig, layout, localBinary, args.appVersion, localBinarySha256); await signUploadedRuntimeBinaryIfNeeded(ssh, layout, arch.platform); runtimeUploaded = true; runtimeVersion = args.appVersion; @@ -661,7 +916,7 @@ export async function bootstrapRemoteRuntime(args: { ].join(" && ") + " || true"); const shouldUploadNativeDeps = runtimeUploaded || nativeDepsCheck.stdout.trim() !== "ok"; if (shouldUploadNativeDeps) { - await uploadNativeDepsBundle(ssh, layout, arch.label, nativeDepsBundle, args.appVersion); + await uploadNativeDepsBundle(ssh, args.target, connectedRoute, connectedConfig, layout, arch.label, nativeDepsBundle, args.appVersion); } nativeDepsReady = true; } diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts index 203cb27a2..3c622671c 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts @@ -38,6 +38,7 @@ type SshListener = (...args: unknown[]) => void; type FakeSshClient = Client & { emitOnce(event: "close" | "error", ...args: unknown[]): void; + destroy: ReturnType; end: ReturnType; once: ReturnType; }; @@ -127,9 +128,11 @@ function createSsh(): FakeSshClient { const listeners = new Map(); const fake = {} as { emitOnce?: FakeSshClient["emitOnce"]; + destroy?: ReturnType; end?: ReturnType; once?: ReturnType; }; + fake.destroy = vi.fn(); fake.end = vi.fn(); fake.once = vi.fn((event: string, callback: SshListener): FakeSshClient => { const existing = listeners.get(event) ?? []; @@ -207,9 +210,42 @@ describe("RemoteConnectionPool", () => { expect(client.close).toHaveBeenCalledTimes(1); expect(ssh.end).toHaveBeenCalledTimes(1); + expect(ssh.destroy).toHaveBeenCalledTimes(1); expect(onEvicted).not.toHaveBeenCalled(); }); + it("reuses an in-flight bootstrap when reconnect follows disconnect", async () => { + const client = createClient(); + const ssh = createSsh(); + type BootstrapResolve = (value: { + client: RuntimeRpcClient; + ssh: Client; + result: RemoteRuntimeConnectResult; + }) => void; + let resolveBootstrap: BootstrapResolve | undefined; + bootstrapRemoteRuntimeMock.mockReturnValueOnce(new Promise((resolve) => { + resolveBootstrap = resolve; + })); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + const firstConnect = pool.connect(target); + pool.disconnect(target.id); + const secondConnect = pool.connect(target); + + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(1); + const resolveBootstrapNow = resolveBootstrap as BootstrapResolve; + resolveBootstrapNow({ + client, + ssh, + result: connectResult("1.0.0"), + }); + + await expect(firstConnect).resolves.toMatchObject({ version: "1.0.0" }); + await expect(secondConnect).resolves.toMatchObject({ version: "1.0.0" }); + expect(client.close).not.toHaveBeenCalled(); + expect(ssh.end).not.toHaveBeenCalled(); + }); + it("evicts cached entries and closes the RPC client after SSH closes", async () => { const firstClient = createClient(); const firstSsh = createSsh(); @@ -225,6 +261,7 @@ describe("RemoteConnectionPool", () => { expect(firstClient.close).toHaveBeenCalledTimes(1); expect(firstSsh.end).toHaveBeenCalledTimes(1); + expect(firstSsh.destroy).toHaveBeenCalledTimes(1); bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ client: createClient(), @@ -390,6 +427,19 @@ describe("RemoteConnectionPool", () => { expect(secondClient.call).toHaveBeenCalledWith("projects.list", {}); }); + it("backs off new SSH bootstraps after a connect failure", async () => { + bootstrapRemoteRuntimeMock.mockRejectedValueOnce( + new Error("kex_exchange_identification: read: Connection reset by peer"), + ); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect(pool.connect(target)).rejects.toThrow( + /kex_exchange_identification/i, + ); + await expect(pool.connect(target)).rejects.toThrow(/Retrying in \d+s/i); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(1); + }); + it("does not replay non-idempotent machine calls after a connection interruption", async () => { const firstClient = createClient(); firstClient.call.mockRejectedValueOnce( diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts index 44cbc9c9c..13ec75769 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts @@ -24,12 +24,38 @@ type PoolEntry = { dispose?: (closeClient: boolean, notify?: boolean) => void; }; +function closePoolEntryResources( + entry: PoolEntry, + closeClient: boolean, + forceSshDestroy = false, +): void { + if (closeClient) { + try { + entry.client.close(); + } catch {} + } + try { + entry.ssh.end(); + } catch {} + if (forceSshDestroy) { + try { + (entry.ssh as unknown as { destroy?: () => void }).destroy?.(); + } catch {} + } +} + type RuntimeEventNotification = { subscriptionId: string; projectId: string; event: RemoteRuntimeBufferedEvent; }; +type ConnectFailureBackoff = { + error: Error; + failureCount: number; + retryAfterMs: number; +}; + function isRemoteRuntimeConnectionError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return /remote (?:runtime|ADE service) connection (?:closed|failed)|timed out waiting for method|stream closed|channel closed|connection lost|socket closed|ECONNRESET|ECONNABORTED|EPIPE|ENOTCONN/i.test( @@ -38,6 +64,8 @@ function isRemoteRuntimeConnectionError(error: unknown): boolean { } const RETRYABLE_REMOTE_ACTION_RPC_TIMEOUT_MS = 25_000; +const CONNECT_FAILURE_BASE_BACKOFF_MS = 3_000; +const CONNECT_FAILURE_MAX_BACKOFF_MS = 15_000; const RETRYABLE_REMOTE_ACTION_PREFIXES = [ "diagnosticsGet", @@ -125,6 +153,12 @@ function assertMachineProjectCapability(entry: PoolEntry, method: string): void export class RemoteConnectionPool { private readonly entries = new Map>(); + private readonly pendingDisconnects = new Set(); + private readonly resolvedEntryPromises = new Set>(); + private readonly connectFailureBackoffByTargetId = new Map< + string, + ConnectFailureBackoff + >(); private readonly evictionListeners = new Set< (targetId: string, error: Error) => void >(); @@ -177,7 +211,11 @@ export class RemoteConnectionPool { private async connectEntry(target: RemoteRuntimeTarget): Promise { const existing = this.entries.get(target.id); - if (existing) return await existing; + if (existing) { + this.pendingDisconnects.delete(target.id); + return await existing; + } + this.assertConnectNotBackingOff(target.id); const pending = bootstrapRemoteRuntime({ target, registry: this.registry, @@ -187,6 +225,8 @@ export class RemoteConnectionPool { let entryPromise: Promise; entryPromise = pending.then(({ client, ssh, result }) => { const entry = { client, ssh, result }; + this.connectFailureBackoffByTargetId.delete(target.id); + this.resolvedEntryPromises.add(entryPromise); this.attachEntryLifecycle(target.id, entryPromise, entry); return entry; }); @@ -195,6 +235,9 @@ export class RemoteConnectionPool { return await entryPromise; } catch (error) { this.entries.delete(target.id); + this.pendingDisconnects.delete(target.id); + this.resolvedEntryPromises.delete(entryPromise); + this.noteConnectFailure(target.id, error); throw error; } } @@ -499,21 +542,42 @@ export class RemoteConnectionPool { disconnect(targetId: string): void { const existing = this.entries.get(targetId); - this.entries.delete(targetId); + if (!existing) return; + if (this.resolvedEntryPromises.has(existing)) { + this.entries.delete(targetId); + this.resolvedEntryPromises.delete(existing); + void existing + .then((entry) => { + if (entry.dispose) { + entry.dispose(true, false); + return; + } + closePoolEntryResources(entry, true, true); + }) + .catch(() => {}); + return; + } + this.pendingDisconnects.add(targetId); void existing ?.then((entry) => { + if (!this.pendingDisconnects.delete(targetId)) return; + if (this.entries.get(targetId) === existing) { + this.entries.delete(targetId); + } + this.resolvedEntryPromises.delete(existing); if (entry.dispose) { entry.dispose(true, false); return; } - try { - entry.client.close(); - } catch {} - try { - entry.ssh.end(); - } catch {} + closePoolEntryResources(entry, true, true); }) - .catch(() => {}); + .catch(() => { + this.pendingDisconnects.delete(targetId); + this.resolvedEntryPromises.delete(existing); + if (this.entries.get(targetId) === existing) { + this.entries.delete(targetId); + } + }); } dispose(): void { @@ -574,16 +638,10 @@ export class RemoteConnectionPool { if (this.entries.get(targetId) === entryPromise) { this.entries.delete(targetId); } + this.resolvedEntryPromises.delete(entryPromise); if (cleanedUp) return; cleanedUp = true; - if (closeClient) { - try { - entry.client.close(); - } catch {} - } - try { - entry.ssh.end(); - } catch {} + closePoolEntryResources(entry, closeClient, true); if (notify) notifyEvicted(error); }; @@ -592,6 +650,36 @@ export class RemoteConnectionPool { entry.ssh.once("error", (error) => evict(true, true, error instanceof Error ? error : new Error(String(error)))); entry.dispose = evict; } + + private assertConnectNotBackingOff(targetId: string): void { + const backoff = this.connectFailureBackoffByTargetId.get(targetId); + if (!backoff) return; + const remainingMs = backoff.retryAfterMs - Date.now(); + if (remainingMs <= 0) { + this.connectFailureBackoffByTargetId.delete(targetId); + return; + } + const seconds = Math.max(1, Math.ceil(remainingMs / 1000)); + throw new Error( + `Remote ADE service connection failed recently (${backoff.error.message}). Retrying in ${seconds}s.`, + ); + } + + private noteConnectFailure(targetId: string, error: unknown): void { + const normalizedError = + error instanceof Error ? error : new Error(String(error)); + const previous = this.connectFailureBackoffByTargetId.get(targetId); + const failureCount = (previous?.failureCount ?? 0) + 1; + const backoffMs = Math.min( + CONNECT_FAILURE_MAX_BACKOFF_MS, + CONNECT_FAILURE_BASE_BACKOFF_MS * 2 ** Math.max(0, failureCount - 1), + ); + this.connectFailureBackoffByTargetId.set(targetId, { + error: normalizedError, + failureCount, + retryAfterMs: Date.now() + backoffMs, + }); + } } function clampCursor(value: unknown): number { diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts index 130c17095..fd419a6ed 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts @@ -12,6 +12,10 @@ import type { RemoteRuntimeConnectionStatus, RemoteRuntimeConnectResult, RemoteRuntimeProjectRecord, + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, RemoteRuntimeSshHostKeyTrustStatus, RemoteRuntimeTarget, RemoteRuntimeTargetInput, @@ -331,6 +335,62 @@ export class RemoteConnectionService { )) as ListMyGitHubReposResult; } + async callAction( + targetId: string, + projectId: string, + request: RemoteRuntimeActionRequest, + ): Promise { + const target = this.requireTarget(targetId); + try { + const result = await this.pool.callActionForTarget( + target, + projectId, + request, + ); + this.mergeStatus(targetId, { + state: "connected", + lastError: null, + lastAttemptedAt: Date.now(), + }); + return result; + } catch (error) { + this.mergeStatus(targetId, { + state: "error", + lastError: errorMessage(error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + async streamEvents( + targetId: string, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + ): Promise { + const target = this.requireTarget(targetId); + try { + const result = await this.pool.streamEventsForTarget( + target, + projectId, + request, + ); + this.mergeStatus(targetId, { + state: "connected", + lastError: null, + lastAttemptedAt: Date.now(), + }); + return result; + } catch (error) { + this.mergeStatus(targetId, { + state: "error", + lastError: errorMessage(error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + dispose(): void { this.stopAutoconnect(); this.pool.dispose(); diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts index 694829948..74102c5a4 100644 --- a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts @@ -86,7 +86,7 @@ describe("buildSshConfig", () => { port: 22, username: "ade", readyTimeout: 20_000, - keepaliveInterval: 15_000, + keepaliveInterval: 0, keepaliveCountMax: 3, agent: "/tmp/ade-agent.sock", }); diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts index 2c6606240..abd24ed55 100644 --- a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts @@ -39,6 +39,12 @@ export type BuildSshConfigOptions = { export type ConnectedSshRoute = RemoteRuntimeTargetRoute; +export type ConnectedSshSession = { + client: Client; + route: ConnectedSshRoute; + config: ConnectConfig; +}; + type KnownHostEntry = { marker: string | null; hosts: string; @@ -541,7 +547,10 @@ export function buildSshConfig(target: RemoteRuntimeTarget, options: BuildSshCon port: endpoint.port, username: endpoint.username, readyTimeout: 20_000, - keepaliveInterval: 15_000, + // Runtime RPC calls and artifact uploads have their own timeouts. SSH-level + // keepalives can starve behind large channel writes and close otherwise + // healthy uploads, so keep the transport-level probe disabled. + keepaliveInterval: 0, keepaliveCountMax: 3, }; const identityFile = target.sshKeyPath @@ -755,14 +764,14 @@ function buildSshConnectionCandidates( export async function connectSshWithRoute( target: RemoteRuntimeTarget, -): Promise<{ client: Client; route: ConnectedSshRoute }> { +): Promise { const configs = buildSshConnectionCandidates(target); let lastError: unknown = null; for (let index = 0; index < configs.length; index += 1) { const candidate = configs[index]!; try { const client = await connectSshWithConfig(candidate.config); - return { client, route: candidate.route }; + return { client, route: candidate.route, config: candidate.config }; } catch (error) { lastError = error; if (index >= configs.length - 1) break; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index fc1a62044..f23ce8e47 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -5331,6 +5331,71 @@ describe("preload remote project binding", () => { }); }); + it("waits for remote project open before routing read-only project calls", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + let resolveOpen: (value: typeof binding) => void = () => {}; + const remoteRuntimeProjects: string[] = []; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding: null }; + } + if (channel === IPC.remoteRuntimeOpenProject) { + return await new Promise((resolve) => { + resolveOpen = resolve as (value: typeof binding) => void; + }); + } + if (channel === IPC.remoteRuntimeCallAction) { + remoteRuntimeProjects.push( + (payload as { projectId?: string } | undefined)?.projectId ?? "", + ); + return { result: [{ id: "lane-remote" }] }; + } + throw new Error(`unexpected IPC: ${channel}`); + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((_name: string, value: unknown) => { + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + const bridge = (globalThis as any).__adeBridge; + + const pendingOpen = bridge.remoteRuntime.openProject("target-1", "project-1"); + const pendingLaneList = bridge.lanes.list(); + await Promise.resolve(); + + expect(invoke).not.toHaveBeenCalledWith( + IPC.remoteRuntimeCallAction, + expect.anything(), + ); + expect(invoke.mock.calls.some(([channel]) => channel === IPC.lanesList)) + .toBe(false); + + resolveOpen(binding); + await pendingOpen; + await expect(pendingLaneList).resolves.toEqual([{ id: "lane-remote" }]); + expect(remoteRuntimeProjects).toEqual(["project-1"]); + }); + it("blocks mutating sync calls while a remote project switch is in flight", async () => { let resolveOpen: (value: unknown) => void = () => {}; const binding = { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 35a6d76fe..dfc413257 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -997,6 +997,7 @@ let projectBindingGeneration = 0; let projectBindingVersion = 0; let projectBindingRefreshPromise: Promise | null = null; let projectRuntimeTransitionDepth = 0; +let activeRemoteProjectOpenPromise: Promise | null = null; function rememberProjectBinding(binding: OpenProjectBinding | null): void { const previousKey = currentProjectBinding?.key ?? null; @@ -1281,7 +1282,19 @@ function shouldBypassProjectRuntimeDuringTransition(domain: string, action: stri : "changing project state"; throw new Error(PROJECT_SWITCHING_MESSAGE.replace("changing project state", label)); } - return activeRemoteProjectOpenGeneration !== null; + return false; +} + +async function waitForRemoteProjectOpenIfActive(): Promise { + const generation = activeRemoteProjectOpenGeneration; + const promise = activeRemoteProjectOpenPromise; + if (generation == null || !promise) return false; + try { + await promise; + } catch { + return false; + } + return activeRemoteProjectOpenGeneration !== generation; } async function callProjectRuntimeActionIfBound( @@ -1304,6 +1317,13 @@ async function callProjectRuntimeActionIfBound( if (freshBinding && !isMutatingChatAction && projectRuntimeTransitionDepth > 0) { return { handled: false }; } + if ( + activeRemoteProjectOpenGeneration !== null && + !isMutatingRuntimeAction(domain, action) && + await waitForRemoteProjectOpenIfActive() + ) { + return callProjectRuntimeActionIfBound(domain, action, request); + } const remote = await callRemoteProjectActionIfBound( domain, action, @@ -3383,22 +3403,30 @@ contextBridge.exposeInMainWorld("ade", { const generation = ++openRemoteProjectGeneration; activeRemoteProjectOpenGeneration = generation; rememberProjectBinding(null); + const openPromise = ipcRenderer.invoke(IPC.remoteRuntimeOpenProject, { + id, + projectId, + }) as Promise; + activeRemoteProjectOpenPromise = openPromise; try { - const binding = (await ipcRenderer.invoke(IPC.remoteRuntimeOpenProject, { - id, - projectId, - })) as OpenProjectBinding; + const binding = await openPromise; if (generation === openRemoteProjectGeneration) { rememberProjectBinding(binding); activeRemoteProjectOpenGeneration = null; + activeRemoteProjectOpenPromise = null; } return binding; } catch (error) { if (generation === openRemoteProjectGeneration) { await refreshProjectBinding().catch(() => {}); activeRemoteProjectOpenGeneration = null; + activeRemoteProjectOpenPromise = null; } throw error; + } finally { + if (generation === openRemoteProjectGeneration) { + activeRemoteProjectOpenPromise = null; + } } }); }, diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 7a537e938..b9f3d0bea 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -249,9 +249,7 @@ function isTrustedGitHubUrl(rawUrl: string): boolean { } export function isLaneDeleteProgressActive(progress: LaneDeleteProgress | null | undefined): boolean { - return progress?.overallStatus === "running" - || progress?.overallStatus === "completed" - || progress?.overallStatus === "completed_with_warnings"; + return progress?.overallStatus === "running"; } export function buildLaneSplitColumnsKey(args: { From e15b9f5f01c595099a02a21643039811cd946525 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 06:29:21 -0400 Subject: [PATCH 05/80] fix(remote): explain SSH handshake resets --- .../remoteRuntime/sshTransport.test.ts | 32 ++++++++ .../services/remoteRuntime/sshTransport.ts | 81 ++++++++++++++++++- .../remoteTargets/RemoteTargetList.test.tsx | 45 +++++++++++ .../remoteTargets/RemoteTargetList.tsx | 23 ++++-- 4 files changed, 173 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts index 74102c5a4..243fdb898 100644 --- a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import { createHmac } from "node:crypto"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -12,6 +13,7 @@ import { getSshHostKeyTrustForTarget, hasKnownSshHostKeyForTarget, parseOpenSshHostConfig, + scanSshHostKeyForTarget, trustSshHostKeyForTarget, type ScannedSshHostKey, } from "./sshTransport"; @@ -478,6 +480,36 @@ describe("buildSshConfig", () => { }); }); + it("explains SSH servers that close before the handshake during host-key scan", async () => { + const server = net.createServer((socket) => { + socket.destroy(); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Expected TCP test server to bind an address."); + } + + try { + await expect(scanSshHostKeyForTarget({ + ...target, + hostname: "127.0.0.1", + port: address.port, + }, { + env: {}, + sshConfigPath: null, + })).rejects.toThrow( + `SSH server at 127.0.0.1:${address.port} closed the connection before ADE could finish the SSH handshake.`, + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("rejects unknown SSH host keys by default", () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ssh-home-")); const config = buildSshConfig(target, { diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts index abd24ed55..81b9ed43b 100644 --- a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts @@ -692,12 +692,87 @@ function isSshAuthenticationFailure(error: unknown): boolean { (typeof candidate.message === "string" && /authentication/i.test(candidate.message)); } +function sshEndpointLabel(config: ConnectConfig): string { + const host = typeof config.host === "string" && config.host.trim() + ? config.host.trim() + : "remote host"; + const port = typeof config.port === "number" && Number.isFinite(config.port) + ? config.port + : 22; + return `${host}:${port}`; +} + +function copySshErrorMetadata(source: unknown, target: Error): Error { + if (!source || typeof source !== "object") return target; + const candidate = source as { + code?: unknown; + errno?: unknown; + syscall?: unknown; + level?: unknown; + }; + for (const key of ["code", "errno", "syscall", "level"] as const) { + if (candidate[key] !== undefined) { + Object.defineProperty(target, key, { + configurable: true, + enumerable: false, + value: candidate[key], + }); + } + } + Object.defineProperty(target, "cause", { + configurable: true, + enumerable: false, + value: source, + }); + return target; +} + +function normalizeSshConnectError( + error: unknown, + fallback: string, + config: ConnectConfig, +): Error { + const source = error instanceof Error + ? error + : new Error(String(error ?? fallback)); + const message = source.message || fallback; + const endpoint = sshEndpointLabel(config); + const sourceMetadata = source as Error & { code?: unknown }; + const code = typeof sourceMetadata.code === "string" + ? sourceMetadata.code + : ""; + + if ( + code === "ECONNRESET" || + /(?:^|\s)ECONNRESET(?:\s|$)|connection reset|connection closed by remote host|socket closed|connection lost before handshake/i.test(message) || + /closed before (?:it became ready|ADE could complete the SSH handshake)/i.test(fallback) + ) { + return copySshErrorMetadata( + source, + new Error( + `SSH server at ${endpoint} closed the connection before ADE could finish the SSH handshake. ` + + "Check that Remote Login/sshd is enabled on the remote machine and that its firewall or Tailscale SSH policy allows this client.", + ), + ); + } + + if (/timed out while waiting for handshake|handshake.*timed out|ready timeout/i.test(message)) { + return copySshErrorMetadata( + source, + new Error( + `Timed out while waiting for the SSH handshake from ${endpoint}. ` + + "Check that the machine is online, reachable over this route, and accepting SSH connections.", + ), + ); + } + + return source; +} + function connectSshWithConfig(config: ConnectConfig): Promise { return new Promise((resolve, reject) => { const client = new Client(); let settled = false; - const normalizeError = (error: unknown, fallback: string): Error => - error instanceof Error ? error : new Error(String(error ?? fallback)); const cleanupConnectListeners = (options: { keepErrorListener?: boolean } = {}): void => { client.off("ready", onReady); if (!options.keepErrorListener) { @@ -713,7 +788,7 @@ function connectSshWithConfig(config: ConnectConfig): Promise { // connect attempt. Keep onError attached as a sink so the late error is // contained after the promise has already rejected. cleanupConnectListeners({ keepErrorListener: true }); - reject(normalizeError(error, fallback)); + reject(normalizeSshConnectError(error, fallback, config)); }; const onReady = (): void => { if (settled) return; diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx index ceb34860c..2955ada3e 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx @@ -202,6 +202,51 @@ describe("RemoteTargetList", () => { expect(screen.queryByRole("button", { name: "Open" })).toBeNull(); }); + it("shows an actionable message when the SSH host-key probe is reset", async () => { + const target = { + id: "target-1", + name: "Mac Studio", + hostname: "100.75.20.63", + sshUser: "admin", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, + }; + remoteRuntimeMock.listTargets.mockResolvedValue([target]); + remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ + machines: [], + diagnostics: [], + }); + installAdeMock(); + remoteRuntimeMock.getSshHostKeyTrust.mockRejectedValue( + new Error( + "Error invoking remote method 'ade.remoteRuntime.getSshHostKeyTrust': Error: read ECONNRESET", + ), + ); + + render(); + + await waitFor(() => + expect(screen.getAllByText("Mac Studio").length).toBeGreaterThan(0), + ); + const connectButton = screen + .getAllByRole("button", { name: "Connect" }) + .find((button) => !button.hasAttribute("disabled")); + expect(connectButton).toBeTruthy(); + fireEvent.click(connectButton!); + + await waitFor(() => + expect( + screen.getByText(/SSH server closed the connection before ADE could finish the SSH handshake/), + ).toBeTruthy(), + ); + expect(screen.queryByText(/Error invoking remote method/)).toBeNull(); + expect(screen.queryByText(/read ECONNRESET/)).toBeNull(); + expect(remoteRuntimeMock.connect).not.toHaveBeenCalled(); + }); + it("prompts to trust a new machine identity before connecting", async () => { const target = { id: "target-1", diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index 4bebd684e..fd5cb609d 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -265,6 +265,19 @@ function connectionStateLabel( return "Not connected"; } +function formatRemoteTargetError(error: unknown): string { + const message = extractError(error) + .replace(/^Error invoking remote method '[^']+':\s*/i, "") + .replace(/^Error:\s*/i, "") + .trim(); + + if (/^(?:read\s+)?ECONNRESET$/i.test(message)) { + return "SSH server closed the connection before ADE could finish the SSH handshake. Check that Remote Login/sshd is enabled on the remote machine and try again."; + } + + return message || "Remote connection failed."; +} + export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { const [targets, setTargets] = useState([]); const [connectionSnapshot, setConnectionSnapshot] = @@ -336,7 +349,7 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { setSelectedId((current) => current ?? next[0]?.id ?? null); setError(null); } catch (err) { - setError(extractError(err)); + setError(formatRemoteTargetError(err)); } finally { setLoading(false); } @@ -514,7 +527,7 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { setError(null); onConnected?.(result); } catch (err) { - setError(extractError(err)); + setError(formatRemoteTargetError(err)); } finally { setBusyId(null); } @@ -536,7 +549,7 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { skipHostKeyTrustCheck: true, }); } catch (err) { - setError(extractError(err)); + setError(formatRemoteTargetError(err)); } finally { setTrustingHostKey(false); } @@ -562,7 +575,7 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { setError(null); await connectTarget(target.id); } catch (err) { - setError(extractError(err)); + setError(formatRemoteTargetError(err)); } finally { setSaving(false); } @@ -585,7 +598,7 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { if (formPrefill?.targetId === targetId) setFormPrefill(null); setError(null); } catch (err) { - setError(extractError(err)); + setError(formatRemoteTargetError(err)); } finally { setBusyId(null); } From f0d1ecb2645189d5ef7fa39071a6e7467737e636 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:48:30 -0400 Subject: [PATCH 06/80] fix(remote): avoid autoconnecting new targets --- .../remoteConnectionService.test.ts | 76 +++++++++++++++++++ .../remoteRuntime/remoteConnectionService.ts | 6 ++ 2 files changed, 82 insertions(+) create mode 100644 apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.test.ts diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.test.ts new file mode 100644 index 000000000..5097cff58 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + RemoteRuntimeConnectResult, + RemoteRuntimeTarget, +} from "../../../shared/types/remoteRuntime"; +import type { RemoteConnectionPool } from "./remoteConnectionPool"; +import { RemoteConnectionService } from "./remoteConnectionService"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +function target( + id: string, + lastConnectedAt: number | null, +): RemoteRuntimeTarget { + return { + id, + name: id, + hostname: `${id}.example.test`, + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt, + }; +} + +function connectResult(target: RemoteRuntimeTarget): RemoteRuntimeConnectResult { + return { + target, + arch: "darwin-arm64", + version: "1.0.0", + projects: [], + capabilities: { + projects: true, + machineProjects: { + browseDirectories: true, + getDetail: true, + getWorkSummary: true, + getDefaultParentDir: true, + create: true, + clone: true, + listMyGitHubRepos: true, + }, + }, + }; +} + +describe("RemoteConnectionService", () => { + it("only autoconnects targets that have connected successfully before", async () => { + const neverConnected = target("never-connected", null); + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [neverConnected, previouslyConnected]), + get: vi.fn((id: string) => + id === neverConnected.id + ? neverConnected + : id === previouslyConnected.id + ? previouslyConnected + : null, + ), + } as unknown as RemoteTargetRegistry; + const pool = { + connect: vi.fn(async (target: RemoteRuntimeTarget) => connectResult(target)), + disconnect: vi.fn(), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + service.startAutoconnect(); + await Promise.resolve(); + service.stopAutoconnect(); + + expect(pool.connect).toHaveBeenCalledTimes(1); + expect(pool.connect).toHaveBeenCalledWith(previouslyConnected); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts index fd419a6ed..e1e609b5a 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts @@ -53,6 +53,10 @@ function coerceConnectionProject(value: unknown): RemoteRuntimeProjectRecord { return project; } +function shouldAutoconnectTarget(target: RemoteRuntimeTarget): boolean { + return target.lastConnectedAt != null; +} + export class RemoteConnectionService { private readonly statusById = new Map(); private readonly listeners = new Set< @@ -152,6 +156,7 @@ export class RemoteConnectionService { startAutoconnect(): void { for (const target of this.registry.list()) { + if (!shouldAutoconnectTarget(target)) continue; void this.connect(target.id).catch(() => {}); } if (this.autoconnectTimer) return; @@ -401,6 +406,7 @@ export class RemoteConnectionService { options: { pingTimeoutMs?: number } = {}, ): Promise { for (const target of this.registry.list()) { + if (!shouldAutoconnectTarget(target)) continue; const status = this.statusById.get(target.id); if (status?.state === "connecting") continue; if (status?.state === "connected") { From f5f995f0bd61961603691a7a950d800223a5c942 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:21:54 -0400 Subject: [PATCH 07/80] fix(remote): guard runtime upload ssh channels --- .../remoteRuntime/remoteBootstrap.test.ts | 22 ++++++++++++++----- .../services/remoteRuntime/remoteBootstrap.ts | 20 +++++++++++++++-- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 8c2759cbb..6cbfa036e 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -28,6 +28,11 @@ const initializeMock = vi.hoisted(() => vi.fn()); const callMock = vi.hoisted(() => vi.fn()); const runtimeRpcClientMock = vi.hoisted(() => vi.fn()); +const guardedUploadCommandPattern = (remoteFilePattern: string): RegExp => + new RegExp( + String.raw`^umask 077; cat >> ${remoteFilePattern} & ade_upload_pid=\$!; .*sleep 75; .*exit "\$ade_upload_status"$`, + ); + vi.mock("node:child_process", () => ({ spawn: spawnMock, })); @@ -522,7 +527,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { expect(connectSshWithRouteMock).toHaveBeenCalledWith(targetFromSshConfig); expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(/^umask 077; cat >> \$HOME\/\.ade\/bin\/ade\.upload-.*\.tmp$/), + expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade/bin/ade\.upload-.*\.tmp`)), expect.any(Function), ); expect(spawnMock).not.toHaveBeenCalled(); @@ -609,7 +614,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { })).rejects.toThrow(/uploaded ade service version mismatch/i); expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(/^umask 077; cat >> \$HOME\/\.ade\/bin\/ade\.upload-.*\.tmp$/), + expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade/bin/ade\.upload-.*\.tmp`)), expect.any(Function), ); expect(spawnMock).not.toHaveBeenCalled(); @@ -655,12 +660,17 @@ describe("bootstrapRemoteRuntime upload flow", () => { })).rejects.toThrow(/existing SSH session: SSH upload channel failed.*channel denied.*OpenSSH fallback failed: SSH upload process failed.*pipe broke/i); expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(/^umask 077; cat >> \$HOME\/\.ade\/bin\/ade\.upload-.*\.tmp$/), + expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade/bin/ade\.upload-.*\.tmp`)), expect.any(Function), ); expect(spawnMock).toHaveBeenCalledWith( "ssh", - expect.arrayContaining(["-p", "22", "ade@build-host.local", expect.stringMatching(/^umask 077; cat >> \$HOME\/\.ade\/bin\/ade\.upload-.*\.tmp$/)]), + expect.arrayContaining([ + "-p", + "22", + "ade@build-host.local", + expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade/bin/ade\.upload-.*\.tmp`)), + ]), expect.objectContaining({ stdio: [expect.any(Number), "ignore", "pipe"] }), ); expect(commands.some((command) => command.startsWith("rm -f $HOME/.ade/bin/ade.upload-"))).toBe(true); @@ -724,11 +734,11 @@ describe("bootstrapRemoteRuntime upload flow", () => { }); expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(/^umask 077; cat >> \$HOME\/\.ade-alpha\/bin\/ade\.upload-.*\.tmp$/), + expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade-alpha/bin/ade\.upload-.*\.tmp`)), expect.any(Function), ); expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(/^umask 077; cat >> \$HOME\/\.ade-alpha\/runtime\/ade-darwin-arm64\.native\.tar\.gz\.upload-.*\.tmp$/), + expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade-alpha/runtime/ade-darwin-arm64\.native\.tar\.gz\.upload-.*\.tmp`)), expect.any(Function), ); expect(spawnMock).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index 15b25f615..dc2e44ad6 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -291,6 +291,19 @@ function remoteUploadTempSuffix(): string { return `upload-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}.tmp`; } +function remoteUploadAppendCommand(remoteFileExpr: string): string { + return [ + "umask 077", + `cat >> ${remoteFileExpr} & ade_upload_pid=$!`, + `( sleep ${REMOTE_ARTIFACT_UPLOAD_WATCHDOG_SECONDS}; kill "$ade_upload_pid" >/dev/null 2>&1 ) & ade_upload_watchdog=$!`, + "wait \"$ade_upload_pid\"", + "ade_upload_status=$?", + "kill \"$ade_upload_watchdog\" >/dev/null 2>&1 || true", + "wait \"$ade_upload_watchdog\" 2>/dev/null || true", + "exit \"$ade_upload_status\"", + ].join("; "); +} + async function execSshOrThrow(client: Client, command: string, fallback: string): Promise { const result = await execSsh(client, command); if (result.code === 0) return; @@ -310,6 +323,9 @@ function remoteFileMatchesCommand(fileExpr: string, expectedSize: number, expect const REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS = 10 * 60_000; const REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS = 45_000; +const REMOTE_ARTIFACT_UPLOAD_WATCHDOG_SECONDS = Math.ceil( + REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS / 1000, +) + 30; const REMOTE_ARTIFACT_UPLOAD_CHUNK_BYTES = 1024 * 1024; const REMOTE_ARTIFACT_UPLOAD_NO_PROGRESS_RETRIES = 2; @@ -442,7 +458,7 @@ async function uploadSshChunkViaConnectedClient( timeout.unref?.(); resetIdleTimer(); - client.exec(`umask 077; cat >> ${remoteFileExpr}`, (error, channel) => { + client.exec(remoteUploadAppendCommand(remoteFileExpr), (error, channel) => { if (error) { settle(new Error(`SSH upload channel failed${uploadProgressSuffix()}: ${error.message}`)); return; @@ -502,7 +518,7 @@ async function uploadSshChunkViaOpenSsh( try { await new Promise((resolve, reject) => { let settled = false; - const child = spawn("ssh", openSshArgsForRoute(target, route, connectedConfig, `umask 077; cat >> ${remoteFileExpr}`), { + const child = spawn("ssh", openSshArgsForRoute(target, route, connectedConfig, remoteUploadAppendCommand(remoteFileExpr)), { stdio: [chunkHandle.fd, "ignore", "pipe"], }); let stderr = ""; From 67e71dac5ef6d47ede87f1dfd74171e87e6bfdc9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:50:40 -0400 Subject: [PATCH 08/80] fix(remote): avoid same-version runtime reuploads --- .../remoteRuntime/remoteBootstrap.test.ts | 46 ++++++ .../services/remoteRuntime/remoteBootstrap.ts | 140 ++++++++++++------ 2 files changed, 138 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 6cbfa036e..6bb542b7e 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -576,6 +576,52 @@ describe("bootstrapRemoteRuntime upload flow", () => { expect(fakeSsh.end).not.toHaveBeenCalled(); }); + it("tries a same-version runtime before replacing it for a hash-only mismatch", async () => { + const resources = createTempResources(); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + }); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(`${APP_VERSION}\n`); + if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok("previous-local-build-sha\n"); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok("ade 0.0.0\n"); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade") && + command.includes("echo ok") + ) return ok(""); + throw new Error(`Unexpected SSH command: ${command}`); + }); + + const connected = await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(fakeSsh.exec).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + expect(commands).not.toContain("mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin"); + expect(commands.some((command) => command.includes("$HOME/.ade/bin/ade.upload-"))).toBe(false); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade rpc --stdio', + ); + expect(connected.result).toMatchObject({ + arch: "linux-x64", + version: APP_VERSION, + projects: [{ projectId: "project-1", rootPath: "/srv/ade" }], + }); + }); + it("fails closed when an uploaded runtime reports the wrong version", async () => { const resources = createTempResources(); cleanupResources = resources.cleanup; diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index dc2e44ad6..f2c571388 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -908,7 +908,7 @@ export async function bootstrapRemoteRuntime(args: { } } - if (localBinary && localBinarySha256 && shouldUploadBundledRuntime({ + let shouldUploadRuntime = Boolean(localBinary && localBinarySha256 && shouldUploadBundledRuntime({ localBinaryAvailable: true, executableVersion: executableRuntimeVersion, markerVersion: markedRuntimeVersion, @@ -916,35 +916,54 @@ export async function bootstrapRemoteRuntime(args: { localBinarySha256, remoteBinarySha256, remoteBinaryMatchesLocal, - })) { + })); + const deferSameVersionUpload = Boolean( + shouldUploadRuntime + && localBinary + && localBinarySha256 + && runtimeVersion === args.appVersion + && executableRuntimeVersion, + ); + if (deferSameVersionUpload) { + shouldUploadRuntime = false; + } + + const uploadBundledRuntime = async (): Promise => { + if (!localBinary || !localBinarySha256) return; await uploadRuntimeBinary(ssh, args.target, connectedRoute, connectedConfig, layout, localBinary, args.appVersion, localBinarySha256); await signUploadedRuntimeBinaryIfNeeded(ssh, layout, arch.platform); runtimeUploaded = true; runtimeVersion = args.appVersion; + }; + + if (shouldUploadRuntime) { + await uploadBundledRuntime(); } - let nativeDepsReady = false; - if (nativeDepsBundle) { + const ensureNativeDepsReady = async (forceUpload: boolean): Promise => { + if (!nativeDepsBundle) return false; const nativeDepsCheck = await execSsh(ssh, [ `test -d ${layout.runtimeDirExpr}/${arch.label}/node_modules`, `test "$(cat ${layout.runtimeDirExpr}/${arch.label}/.ade-version 2>/dev/null)" = ${shellQuote(args.appVersion)}`, "echo ok", ].join(" && ") + " || true"); - const shouldUploadNativeDeps = runtimeUploaded || nativeDepsCheck.stdout.trim() !== "ok"; + const shouldUploadNativeDeps = forceUpload || nativeDepsCheck.stdout.trim() !== "ok"; if (shouldUploadNativeDeps) { await uploadNativeDepsBundle(ssh, args.target, connectedRoute, connectedConfig, layout, arch.label, nativeDepsBundle, args.appVersion); } - nativeDepsReady = true; - } + return true; + }; - const runtimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ + let nativeDepsReady = await ensureNativeDepsReady(runtimeUploaded); + + let runtimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ archLabel: arch.label, nativeDepsReady, layout, disableRuntimeServiceInstall: layout.homeDirName !== preferredLayout.homeDirName, }); - if (runtimeUploaded) { + const verifyUploadedRuntime = async (): Promise => { const uploadedVersionCheck = await execSsh(ssh, `${runtimeEnvPrefix}${layout.binaryExpr} --version`); const uploadedVersion = normalizeRuntimeVersion(uploadedVersionCheck.stdout); if (uploadedVersionCheck.code !== 0 || !uploadedVersion) { @@ -957,9 +976,10 @@ export async function bootstrapRemoteRuntime(args: { throw new Error(`Uploaded ADE service version mismatch: expected ${args.appVersion}, got ${uploadedVersion}.`); } runtimeVersion = uploadedVersion; - } + }; if (runtimeUploaded) { + await verifyUploadedRuntime(); await stopRemoteRuntimeDaemon(ssh, layout, runtimeEnvPrefix); } @@ -985,49 +1005,73 @@ export async function bootstrapRemoteRuntime(args: { expectedLayout: layout, }); } catch (error) { - if (localBinary || runtimeUploaded) { - throw error; - } - const attempted = [{ layout: layout.homeDirName, error: runtimeErrorMessage(error) }]; - for (const candidateLayout of resolveRemoteRuntimeLayoutCandidates().filter((candidate) => candidate.homeDirName !== layout.homeDirName)) { - const candidateVersionCheck = await execSsh( - ssh, - `test -x ${candidateLayout.binaryExpr} && ${candidateLayout.binaryExpr} --version || true`, - ); - const candidateRuntimeVersion = normalizeRuntimeVersion(candidateVersionCheck.stdout); - if (!candidateRuntimeVersion) continue; - const candidateNativeDepsCheck = await execSsh( - ssh, - `test -d ${candidateLayout.runtimeDirExpr}/${arch.label}/node_modules && echo ok || true`, - ); - const candidateRuntimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ + let sameVersionUploadRecovered = false; + if (deferSameVersionUpload && localBinary && localBinarySha256) { + await uploadBundledRuntime(); + nativeDepsReady = await ensureNativeDepsReady(true); + runtimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ archLabel: arch.label, - nativeDepsReady: candidateNativeDepsCheck.stdout.trim() === "ok", - layout: candidateLayout, - disableRuntimeServiceInstall: true, + nativeDepsReady, + layout, + disableRuntimeServiceInstall: layout.homeDirName !== preferredLayout.homeDirName, }); - const candidateCommand = `${candidateRuntimeEnvPrefix}ade rpc --stdio`; - try { - openedRuntime = await openValidatedRuntimeClient({ + await verifyUploadedRuntime(); + await stopRemoteRuntimeDaemon(ssh, layout, runtimeEnvPrefix); + openedRuntime = await openValidatedRuntimeClient({ + ssh, + command: `${runtimeEnvPrefix}${layout.binaryExpr} rpc --stdio`, + appVersion: args.appVersion, + expectedVersion, + expectedLayout: layout, + }); + runtimeLayoutFallbackReason = `Updated remote runtime in ${layout.homeDirName} because the existing same-version ADE service could not start compatible RPC: ${runtimeErrorMessage(error)}`; + sameVersionUploadRecovered = true; + } + if (!sameVersionUploadRecovered) { + if (localBinary || runtimeUploaded) { + throw error; + } + const attempted = [{ layout: layout.homeDirName, error: runtimeErrorMessage(error) }]; + for (const candidateLayout of resolveRemoteRuntimeLayoutCandidates().filter((candidate) => candidate.homeDirName !== layout.homeDirName)) { + const candidateVersionCheck = await execSsh( ssh, - command: candidateCommand, - appVersion: args.appVersion, - expectedVersion: null, - expectedLayout: candidateLayout, + `test -x ${candidateLayout.binaryExpr} && ${candidateLayout.binaryExpr} --version || true`, + ); + const candidateRuntimeVersion = normalizeRuntimeVersion(candidateVersionCheck.stdout); + if (!candidateRuntimeVersion) continue; + const candidateNativeDepsCheck = await execSsh( + ssh, + `test -d ${candidateLayout.runtimeDirExpr}/${arch.label}/node_modules && echo ok || true`, + ); + const candidateRuntimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ + archLabel: arch.label, + nativeDepsReady: candidateNativeDepsCheck.stdout.trim() === "ok", + layout: candidateLayout, + disableRuntimeServiceInstall: true, }); - runtimeLayoutFallbackReason = `Using remote runtime home ${candidateLayout.homeDirName} because ${layout.homeDirName} could not start a compatible ADE RPC service: ${runtimeErrorMessage(error)}`; - layout = candidateLayout; - runtimeVersion = candidateRuntimeVersion; - break; - } catch (candidateError) { - attempted.push({ layout: candidateLayout.homeDirName, error: runtimeErrorMessage(candidateError) }); + const candidateCommand = `${candidateRuntimeEnvPrefix}ade rpc --stdio`; + try { + openedRuntime = await openValidatedRuntimeClient({ + ssh, + command: candidateCommand, + appVersion: args.appVersion, + expectedVersion: null, + expectedLayout: candidateLayout, + }); + runtimeLayoutFallbackReason = `Using remote runtime home ${candidateLayout.homeDirName} because ${layout.homeDirName} could not start a compatible ADE RPC service: ${runtimeErrorMessage(error)}`; + layout = candidateLayout; + runtimeVersion = candidateRuntimeVersion; + break; + } catch (candidateError) { + attempted.push({ layout: candidateLayout.homeDirName, error: runtimeErrorMessage(candidateError) }); + } + } + if (!openedRuntime) { + throw new Error( + "Remote ADE service could not start a compatible RPC runtime. " + + `Tried ${attempted.map((attempt) => `${attempt.layout}: ${attempt.error}`).join("; ")}.`, + ); } - } - if (!openedRuntime) { - throw new Error( - "Remote ADE service could not start a compatible RPC runtime. " + - `Tried ${attempted.map((attempt) => `${attempt.layout}: ${attempt.error}`).join("; ")}.`, - ); } } if (!openedRuntime) { From fb155f46e6e584eaee6bc4d25954d50d1e54d536 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:03:59 -0400 Subject: [PATCH 09/80] fix(remote): upload runtimes over sftp --- .../remoteRuntime/remoteBootstrap.test.ts | 74 +++++++-- .../services/remoteRuntime/remoteBootstrap.ts | 154 +++++++++++++++++- 2 files changed, 207 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 6bb542b7e..5a4abd58a 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -321,6 +321,11 @@ function ok(stdout = "") { return { stdout, stderr: "", code: 0 }; } +function resolvedRemotePath(command: string): ReturnType | null { + if (!command.startsWith("printf '%s' ")) return null; + return ok(command.slice("printf '%s' ".length).replace("$HOME", "/home/ade")); +} + function createFakeSpawnProcess(options: { closeCode?: number; error?: Error; stderr?: string } = {}) { const child = new EventEmitter() as EventEmitter & { stdin: EventEmitter & { @@ -370,7 +375,14 @@ function createTempResources( }; } -function createFakeSsh(options: { execError?: Error; channelError?: Error; closeCode?: number; stderr?: string } = {}) { +function createFakeSsh(options: { + execError?: Error; + channelError?: Error; + closeCode?: number; + stderr?: string; + sftpError?: Error; + sftpTransferError?: Error; +} = {}) { const exec = vi.fn((command: string, callback: (error: Error | null, channel: PassThrough & { stderr: PassThrough }) => void) => { const channel = new PassThrough() as PassThrough & { stderr: PassThrough }; channel.stderr = new PassThrough(); @@ -392,9 +404,21 @@ function createFakeSsh(options: { execError?: Error; channelError?: Error; close }); callback(null, channel); }); + const sftpWrapper = Object.assign(new EventEmitter(), { + fastPut: vi.fn((localPath: string, _remotePath: string, transferOptions: { step?: (total: number, nb: number, fsize: number) => void }, callback: (error?: Error) => void) => { + const size = fs.statSync(localPath).size; + transferOptions.step?.(size, size, size); + setImmediate(() => callback(options.sftpTransferError)); + }), + end: vi.fn(), + destroy: vi.fn(), + }); + const sftp = vi.fn((callback: (error: Error | undefined, wrapper: typeof sftpWrapper) => void) => { + setImmediate(() => callback(options.sftpError, sftpWrapper)); + }); const end = vi.fn(); - const ssh = Object.assign(new EventEmitter(), { exec, end }) as unknown as Client; - return { ssh, exec, end }; + const ssh = Object.assign(new EventEmitter(), { exec, sftp, end }) as unknown as Client; + return { ssh, exec, sftp, sftpWrapper, end }; } function createRegistry() { @@ -496,6 +520,8 @@ describe("bootstrapRemoteRuntime upload flow", () => { const commands: string[] = []; execSshMock.mockImplementation(async (_client: Client, command: string) => { commands.push(command); + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; if (command === "uname -sm") return ok("Linux x86_64\n"); if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(""); if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); @@ -526,10 +552,14 @@ describe("bootstrapRemoteRuntime upload flow", () => { }); expect(connectSshWithRouteMock).toHaveBeenCalledWith(targetFromSshConfig); - expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade/bin/ade\.upload-.*\.tmp`)), + expect(fakeSsh.sftp).toHaveBeenCalledTimes(1); + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + resources.binaryPath, + expect.stringMatching(/^\/home\/ade\/\.ade\/bin\/ade\.upload-.*\.tmp$/), + expect.objectContaining({ fileSize: fs.statSync(resources.binaryPath).size, mode: 0o600 }), expect.any(Function), ); + expect(fakeSsh.exec).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); expect(commands.slice(0, 4)).toEqual([ "uname -sm", @@ -632,6 +662,8 @@ describe("bootstrapRemoteRuntime upload flow", () => { route: uploadRoute, }); execSshMock.mockImplementation(async (_client: Client, command: string) => { + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; if (command === "uname -sm") return ok("Linux x86_64\n"); if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(""); if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); @@ -659,10 +691,14 @@ describe("bootstrapRemoteRuntime upload flow", () => { appVersion: APP_VERSION, })).rejects.toThrow(/uploaded ade service version mismatch/i); - expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade/bin/ade\.upload-.*\.tmp`)), + expect(fakeSsh.sftp).toHaveBeenCalledTimes(1); + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + resources.binaryPath, + expect.stringMatching(/^\/home\/ade\/\.ade\/bin\/ade\.upload-.*\.tmp$/), + expect.objectContaining({ fileSize: fs.statSync(resources.binaryPath).size, mode: 0o600 }), expect.any(Function), ); + expect(fakeSsh.exec).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); expect(openSshRuntimeTransportMock).not.toHaveBeenCalled(); expect(initializeMock).not.toHaveBeenCalled(); @@ -673,7 +709,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { it("falls back to OpenSSH only when the connected SSH upload fails before writing", async () => { const resources = createTempResources(); cleanupResources = resources.cleanup; - const fakeSsh = createFakeSsh({ execError: new Error("channel denied") }); + const fakeSsh = createFakeSsh({ sftpError: new Error("sftp denied"), execError: new Error("channel denied") }); spawnMock.mockImplementation(() => createFakeSpawnProcess({ error: new Error("pipe broke") })); const registry = createRegistry(); connectSshWithRouteMock.mockResolvedValue({ @@ -683,6 +719,8 @@ describe("bootstrapRemoteRuntime upload flow", () => { const commands: string[] = []; execSshMock.mockImplementation(async (_client: Client, command: string) => { commands.push(command); + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; if (command === "uname -sm") return ok("Linux x86_64\n"); if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(""); if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); @@ -703,8 +741,9 @@ describe("bootstrapRemoteRuntime upload flow", () => { registry, resourcesPath: resources.resourcesPath, appVersion: APP_VERSION, - })).rejects.toThrow(/existing SSH session: SSH upload channel failed.*channel denied.*OpenSSH fallback failed: SSH upload process failed.*pipe broke/i); + })).rejects.toThrow(/sftp denied.*SSH stream fallback failed.*channel denied.*OpenSSH fallback failed.*pipe broke/i); + expect(fakeSsh.sftp).toHaveBeenCalledTimes(1); expect(fakeSsh.exec).toHaveBeenCalledWith( expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade/bin/ade\.upload-.*\.tmp`)), expect.any(Function), @@ -737,6 +776,8 @@ describe("bootstrapRemoteRuntime upload flow", () => { route: uploadRoute, }); execSshMock.mockImplementation(async (_client: Client, command: string) => { + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; if (command === "uname -sm") return ok("Darwin arm64\n"); if (command === "cat $HOME/.ade-alpha/bin/ade.version 2>/dev/null || true") return ok(""); if (command === "cat $HOME/.ade-alpha/bin/ade.sha256 2>/dev/null || true") return ok(""); @@ -779,14 +820,21 @@ describe("bootstrapRemoteRuntime upload flow", () => { appVersion: APP_VERSION, }); - expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade-alpha/bin/ade\.upload-.*\.tmp`)), + const nativeDepsPath = path.join(resources.resourcesPath, "runtime", "ade-darwin-arm64.native.tar.gz"); + expect(fakeSsh.sftp).toHaveBeenCalledTimes(2); + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + resources.binaryPath, + expect.stringMatching(/^\/home\/ade\/\.ade-alpha\/bin\/ade\.upload-.*\.tmp$/), + expect.objectContaining({ fileSize: fs.statSync(resources.binaryPath).size, mode: 0o600 }), expect.any(Function), ); - expect(fakeSsh.exec).toHaveBeenCalledWith( - expect.stringMatching(guardedUploadCommandPattern(String.raw`\$HOME/\.ade-alpha/runtime/ade-darwin-arm64\.native\.tar\.gz\.upload-.*\.tmp`)), + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + nativeDepsPath, + expect.stringMatching(/^\/home\/ade\/\.ade-alpha\/runtime\/ade-darwin-arm64\.native\.tar\.gz\.upload-.*\.tmp$/), + expect.objectContaining({ fileSize: fs.statSync(nativeDepsPath).size, mode: 0o600 }), expect.any(Function), ); + expect(fakeSsh.exec).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); expect(execSshMock).toHaveBeenCalledWith(fakeSsh.ssh, "codesign --force --sign - $HOME/.ade-alpha/bin/ade"); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index f2c571388..a93739248 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { Client, ConnectConfig } from "ssh2"; +import type { Client, ConnectConfig, SFTPWrapper } from "ssh2"; import type { RemoteRuntimeCapabilities, RemoteRuntimeConnectResult, @@ -310,6 +310,116 @@ async function execSshOrThrow(client: Client, command: string, fallback: string) throw new Error(result.stderr.trim() || result.stdout.trim() || fallback); } +async function resolveRemoteUploadPath(client: Client, remoteFileExpr: string): Promise { + const result = await execSsh(client, `printf '%s' ${remoteFileExpr}`); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || "Unable to resolve remote ADE service artifact path."); + } + const remotePath = result.stdout.trim(); + if (!remotePath) { + throw new Error("Remote ADE service artifact path resolved to an empty value."); + } + return remotePath; +} + +function openSftp(client: Client): Promise { + return new Promise((resolve, reject) => { + client.sftp((error, sftp) => { + if (error) { + reject(error); + return; + } + resolve(sftp); + }); + }); +} + +async function uploadSftpFile( + client: Client, + localPath: string, + remoteFileExpr: string, + totalBytes: number, +): Promise { + const remotePath = await resolveRemoteUploadPath(client, remoteFileExpr); + const sftp = await openSftp(client); + try { + await new Promise((resolve, reject) => { + let settled = false; + let transferredBytes = 0; + let timeout: NodeJS.Timeout | null = null; + let idleTimeout: NodeJS.Timeout | null = null; + + const uploadProgressSuffix = (): string => + transferredBytes > 0 ? ` after ${transferredBytes} transferred bytes` : ""; + + const clearTimers = (): void => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (idleTimeout) { + clearTimeout(idleTimeout); + idleTimeout = null; + } + }; + + const closeSftp = (destroy: boolean): void => { + try { + if (destroy) sftp.destroy(); + else sftp.end(); + } catch {} + }; + + const settle = (error?: Error | null): void => { + if (settled) return; + settled = true; + clearTimers(); + if (error) { + closeSftp(true); + reject(error); + return; + } + resolve(); + }; + + const resetIdleTimer = (): void => { + if (idleTimeout) clearTimeout(idleTimeout); + idleTimeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact with SFTP: no transfer progress for ${REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_IDLE_TIMEOUT_MS); + idleTimeout.unref?.(); + }; + + timeout = setTimeout(() => { + settle(new Error(`Timed out uploading ADE service artifact with SFTP after ${REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS}ms${uploadProgressSuffix()}.`)); + }, REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS); + timeout.unref?.(); + resetIdleTimer(); + + sftp.fastPut(localPath, remotePath, { + concurrency: 4, + chunkSize: 256 * 1024, + fileSize: totalBytes, + mode: 0o600, + step: (total) => { + transferredBytes = total; + resetIdleTimer(); + }, + }, (error) => { + if (error) { + settle(new Error(`Unable to upload ADE service artifact with SFTP${uploadProgressSuffix()}: ${error.message}`)); + return; + } + settle(null); + }); + }); + } finally { + try { + sftp.end(); + } catch {} + } +} + function remoteSha256Expr(fileExpr: string): string { return `(command -v shasum >/dev/null 2>&1 && shasum -a 256 ${fileExpr} || sha256sum ${fileExpr}) | awk '{print $1}'`; } @@ -591,20 +701,15 @@ async function uploadSshChunkViaOpenSsh( } } -async function uploadSshFile( +async function uploadSshFileInChunks( client: Client, target: RemoteRuntimeTarget, route: ConnectedSshRoute, connectedConfig: Pick | null | undefined, localPath: string, remoteFileExpr: string, + totalBytes: number, ): Promise { - const totalBytes = fileSizeBytes(localPath); - await execSshOrThrow( - client, - `rm -f ${remoteFileExpr} && umask 077 && : > ${remoteFileExpr} && chmod 600 ${remoteFileExpr}`, - "Unable to prepare remote ADE service artifact upload.", - ); const readRemoteBytes = async (): Promise => { const result = await execSsh(client, `wc -c < ${remoteFileExpr} | tr -d '[:space:]'`); if (result.code !== 0) { @@ -660,6 +765,39 @@ async function uploadSshFile( } } +async function uploadSshFile( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: Pick | null | undefined, + localPath: string, + remoteFileExpr: string, +): Promise { + const totalBytes = fileSizeBytes(localPath); + const prepareRemoteFile = async (): Promise => { + await execSshOrThrow( + client, + `rm -f ${remoteFileExpr} && umask 077 && : > ${remoteFileExpr} && chmod 600 ${remoteFileExpr}`, + "Unable to prepare remote ADE service artifact upload.", + ); + }; + + await prepareRemoteFile(); + try { + await uploadSftpFile(client, localPath, remoteFileExpr, totalBytes); + return; + } catch (sftpError) { + await prepareRemoteFile(); + try { + await uploadSshFileInChunks(client, target, route, connectedConfig, localPath, remoteFileExpr, totalBytes); + } catch (streamError) { + const sftpMessage = sftpError instanceof Error ? sftpError.message : String(sftpError); + const streamMessage = streamError instanceof Error ? streamError.message : String(streamError); + throw new Error(`Unable to upload ADE service artifact with SFTP: ${sftpMessage}; SSH stream fallback failed: ${streamMessage}`); + } + } +} + async function uploadRuntimeBinary( client: Client, target: RemoteRuntimeTarget, From 738bc23ff00b1e8b636e2d4e93d4d5d306efa84d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:07:03 -0400 Subject: [PATCH 10/80] fix(remote): bound ssh connect failures --- .../main/services/ipc/runtimeBridge.test.ts | 35 ++++++++++- .../src/main/services/ipc/runtimeBridge.ts | 10 +-- .../remoteRuntime/sshTransport.test.ts | 38 ++++++++++- .../services/remoteRuntime/sshTransport.ts | 63 ++++++++++++++++++- 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index e670c53ff..8f3817f37 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -12,7 +12,7 @@ const browserWindowFromWebContents = vi.hoisted(() => vi.fn()); const browserWindowFromId = vi.hoisted(() => vi.fn()); const browserWindowGetAllWindows = vi.hoisted(() => vi.fn(() => [])); const remoteRegistryGetMock = vi.hoisted(() => vi.fn()); -const remoteRegistryListMock = vi.hoisted(() => vi.fn(() => [])); +const remoteRegistryListMock = vi.hoisted(() => vi.fn<[], RemoteRuntimeTarget[]>(() => [])); const remoteRegistrySaveMock = vi.hoisted(() => vi.fn()); const remoteRegistryRemoveMock = vi.hoisted(() => vi.fn()); const remoteConnectMock = vi.hoisted(() => vi.fn()); @@ -190,6 +190,39 @@ describe("registerRuntimeBridge", () => { browserWindowFromWebContents.mockReturnValue({ id: 7 }); }); + it("does not start saved remote autoconnect when disabled for dev/test runs", async () => { + const previous = process.env.ADE_DISABLE_REMOTE_AUTOCONNECT; + process.env.ADE_DISABLE_REMOTE_AUTOCONNECT = "1"; + vi.useFakeTimers(); + remoteRegistryListMock.mockReturnValue([ + { ...target, lastConnectedAt: Date.now() }, + ]); + + try { + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: {} as any, + getWindowSession: () => ({ + windowId: 7, + project: null, + binding: localBinding("/repo"), + }), + }); + + await vi.runOnlyPendingTimersAsync(); + + expect(remoteConnectMock).not.toHaveBeenCalled(); + } finally { + if (previous === undefined) { + delete process.env.ADE_DISABLE_REMOTE_AUTOCONNECT; + } else { + process.env.ADE_DISABLE_REMOTE_AUTOCONNECT = previous; + } + vi.useRealTimers(); + } + }); + it("forwards local project runtime actions with renderer client metadata for file watches", async () => { const localRuntimeConnectionPool = { callActionForRoot: vi.fn(async () => ({ diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 6e621427f..85440ef89 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -301,10 +301,12 @@ export function registerRuntimeBridge({ ); } }); - const autoconnectTimer = setTimeout(() => { - remoteConnectionService.startAutoconnect(); - }, 0); - autoconnectTimer.unref?.(); + if (process.env.ADE_DISABLE_REMOTE_AUTOCONNECT !== "1") { + const autoconnectTimer = setTimeout(() => { + remoteConnectionService.startAutoconnect(); + }, 0); + autoconnectTimer.unref?.(); + } const probeRemoteConnectionsAfterWake = (): void => { remoteConnectionService.probeSavedConnections(); }; diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts index 243fdb898..1e7f59c6b 100644 --- a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts @@ -87,7 +87,7 @@ describe("buildSshConfig", () => { host: "remote.example.test", port: 22, username: "ade", - readyTimeout: 20_000, + readyTimeout: 10_000, keepaliveInterval: 0, keepaliveCountMax: 3, agent: "/tmp/ade-agent.sock", @@ -510,6 +510,42 @@ describe("buildSshConfig", () => { } }); + it("times out SSH sockets that never finish the handshake", async () => { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.once("close", () => { + sockets.delete(socket); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Expected TCP test server to bind an address."); + } + + try { + await expect(scanSshHostKeyForTarget({ + ...target, + hostname: "127.0.0.1", + port: address.port, + }, { + env: { ADE_REMOTE_SSH_CONNECT_TIMEOUT_MS: "75" } as NodeJS.ProcessEnv, + knownHostsPath: null, + sshConfigPath: null, + })).rejects.toThrow( + `Timed out while waiting for the SSH handshake from 127.0.0.1:${address.port}.`, + ); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("rejects unknown SSH host keys by default", () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ssh-home-")); const config = buildSshConfig(target, { diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts index 81b9ed43b..befa936c9 100644 --- a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts @@ -85,6 +85,8 @@ const DEFAULT_IDENTITY_FILES = [ "id_ecdsa_sk", "id_rsa", ]; +const DEFAULT_SSH_CONNECT_ATTEMPT_TIMEOUT_MS = 10_000; +const MIN_SSH_CONNECT_ATTEMPT_TIMEOUT_MS = 50; function stripInlineComment(line: string): string { const hashIndex = line.indexOf("#"); @@ -542,11 +544,12 @@ export async function trustSshHostKeyForTarget( export function buildSshConfig(target: RemoteRuntimeTarget, options: BuildSshConfigOptions = {}): ConnectConfig { const hostConfig = readOpenSshHostConfig(target, options); const endpoint = resolveSshEndpoint(target, options); + const env = options.env ?? process.env; const config: ConnectConfig = { host: endpoint.host, port: endpoint.port, username: endpoint.username, - readyTimeout: 20_000, + readyTimeout: sshConnectAttemptTimeoutMs(env), // Runtime RPC calls and artifact uploads have their own timeouts. SSH-level // keepalives can starve behind large channel writes and close otherwise // healthy uploads, so keep the transport-level probe disabled. @@ -567,13 +570,20 @@ export function buildSshConfig(target: RemoteRuntimeTarget, options: BuildSshCon const knownHostEntries = readKnownHostEntries(endpoint.knownHostsPath); const candidates = knownHostCandidates(endpoint.hostAliases, endpoint.port); config.hostVerifier = (key: Buffer) => knownHostsAllowKey(knownHostEntries, candidates, key); - const env = options.env ?? process.env; if (env.SSH_AUTH_SOCK) { config.agent = env.SSH_AUTH_SOCK; } return config; } +function sshConnectAttemptTimeoutMs(env: NodeJS.ProcessEnv): number { + const raw = env.ADE_REMOTE_SSH_CONNECT_TIMEOUT_MS; + const parsed = raw == null ? NaN : Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= MIN_SSH_CONNECT_ATTEMPT_TIMEOUT_MS + ? parsed + : DEFAULT_SSH_CONNECT_ATTEMPT_TIMEOUT_MS; +} + function uniqueUsernames(values: Array): string[] { const seen = new Set(); const result: string[] = []; @@ -766,14 +776,54 @@ function normalizeSshConnectError( ); } + if (isLocalPortUnavailableError(source)) { + return copySshErrorMetadata( + source, + new Error( + `macOS could not allocate a local TCP port for SSH to ${endpoint}. ` + + "Quit apps that are rapidly opening network connections and try again.", + ), + ); + } + return source; } +function isLocalPortUnavailableError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { code?: unknown; message?: unknown }; + return candidate.code === "EADDRNOTAVAIL" || + candidate.code === "EADDRINUSE" || + (typeof candidate.message === "string" && /EADDRNOTAVAIL|EADDRINUSE|can't assign requested address|address already in use/i.test(candidate.message)); +} + function connectSshWithConfig(config: ConnectConfig): Promise { return new Promise((resolve, reject) => { const client = new Client(); let settled = false; + let connectTimer: NodeJS.Timeout | null = null; + const connectTimeoutMs = + typeof config.readyTimeout === "number" && Number.isFinite(config.readyTimeout) + ? Math.max(MIN_SSH_CONNECT_ATTEMPT_TIMEOUT_MS, config.readyTimeout) + : DEFAULT_SSH_CONNECT_ATTEMPT_TIMEOUT_MS; + const clearConnectTimer = (): void => { + if (!connectTimer) return; + clearTimeout(connectTimer); + connectTimer = null; + }; + const destroyClient = (): void => { + try { + client.end(); + } catch {} + try { + (client as unknown as { destroy?: () => void }).destroy?.(); + } catch {} + try { + (client as unknown as { _sock?: { destroy?: () => void } })._sock?.destroy?.(); + } catch {} + }; const cleanupConnectListeners = (options: { keepErrorListener?: boolean } = {}): void => { + clearConnectTimer(); client.off("ready", onReady); if (!options.keepErrorListener) { client.off("error", onError); @@ -788,8 +838,15 @@ function connectSshWithConfig(config: ConnectConfig): Promise { // connect attempt. Keep onError attached as a sink so the late error is // contained after the promise has already rejected. cleanupConnectListeners({ keepErrorListener: true }); + destroyClient(); reject(normalizeSshConnectError(error, fallback, config)); }; + const onTimeout = (): void => { + const timeoutError = new Error( + `Timed out while waiting for handshake after ${connectTimeoutMs}ms.`, + ); + fail(timeoutError, "Timed out while waiting for handshake."); + }; const onReady = (): void => { if (settled) return; settled = true; @@ -813,6 +870,8 @@ function connectSshWithConfig(config: ConnectConfig): Promise { client.on("error", onError); client.once("close", onClose); client.once("end", onEnd); + connectTimer = setTimeout(onTimeout, connectTimeoutMs); + connectTimer.unref?.(); try { client.connect(config); } catch (error) { From 3142ebff59ebf8dd12d42c3913502101838ba833 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:18:46 -0400 Subject: [PATCH 11/80] fix(work): ignore stale session deep links on project switch --- .../terminals/useWorkSessions.test.ts | 74 ++++++++++++++++++- .../components/terminals/useWorkSessions.ts | 6 +- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index fe9fe0fc6..624619cd0 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -889,7 +889,79 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { // The stale session never existed, so focusSession must not fire for it. expect(focusSessionSpy).not.toHaveBeenCalledWith("missing-session"); - expect(navigateSpy).toHaveBeenCalledWith("/work?sessionId=missing-session", { replace: true }); + expect(navigateSpy).toHaveBeenCalledWith("/work", { replace: true }); + }); + + it("does not replay a previous project's URL session during project switch", async () => { + const sessionA = makeSession("session-a", "lane-a"); + const sessionB = makeSession("session-b", "lane-b"); + listSessionsCachedMock + .mockResolvedValueOnce([sessionA]) + .mockResolvedValue([sessionB]); + + let currentSearchParams = new URLSearchParams("sessionId=session-a"); + useSearchParamsMock.mockImplementation(() => [currentSearchParams, vi.fn()]); + + const projectBWorkState = { + openItemIds: [] as string[], + activeItemId: null as string | null, + selectedItemId: null as string | null, + viewMode: "tabs" as const, + draftKind: "chat" as const, + laneFilter: "all", + statusFilter: "all" as const, + search: "", + sessionListOrganization: "by-lane" as const, + workCollapsedLaneIds: [] as string[], + workCollapsedTabGroupIds: [] as string[], + workFocusSessionsHidden: false, + }; + fakeAppStoreState = { + ...fakeAppStoreState, + project: { rootPath: "/project/a" }, + lanes: [{ id: "lane-a", name: "Lane A" }], + }; + setWorkViewStateSpy.mockImplementation((projectRoot: string, next: any) => { + if (projectRoot !== "/project/b") return; + const resolved = typeof next === "function" + ? next(projectBWorkState) + : { ...projectBWorkState, ...next }; + Object.assign(projectBWorkState, resolved); + }); + + const { rerender } = renderHook(() => useWorkSessions()); + + await waitFor(() => { + expect(focusSessionSpy).toHaveBeenCalledWith("session-a"); + }); + + focusSessionSpy.mockClear(); + selectLaneSpy.mockClear(); + setWorkViewStateSpy.mockClear(); + navigateSpy.mockClear(); + + fakeAppStoreState = { + ...fakeAppStoreState, + project: { rootPath: "/project/b" }, + lanes: [{ id: "lane-b", name: "Lane B" }], + workViewByProject: { + "/project/b": projectBWorkState, + }, + sessionsCacheByProject: {}, + }; + currentSearchParams = new URLSearchParams("sessionId=session-a"); + + act(() => { + rerender(); + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + expect(focusSessionSpy).not.toHaveBeenCalledWith("session-a"); + expect(selectLaneSpy).not.toHaveBeenCalledWith("lane-a"); + expect(projectBWorkState.openItemIds).not.toContain("session-a"); }); it("does not reapply the same URL filters after the Work route is parked on another ADE tab", async () => { diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index 84622d374..90dd29dbf 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -635,7 +635,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const stripUrlFilterParams = useCallback(() => { if (!isWorkRoute) return; const nextParams = new URLSearchParams(searchParams); - for (const key of ["laneId", "lane", "status"]) { + for (const key of ["laneId", "lane", "status", "sessionId"]) { nextParams.delete(key); } // Use URLSearchParams.toString() as the stable comparison anchor: if stripping @@ -955,6 +955,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const sessionParam = (searchParams.get("sessionId") ?? "").trim(); const laneParam = (searchParams.get("laneId") ?? searchParams.get("lane") ?? "").trim(); const statusParam = (searchParams.get("status") ?? "").trim(); + if (pendingProjectSwitchRef.current != null) return; // When a sessionId is requested, only skip the lane/status fallback if // that session actually exists. If it's stale/missing (after the first // load completes) we fall through so the URL's laneId/status hints still @@ -982,7 +983,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) if (!laneExists && !status) { appliedUrlFilterKeyRef.current = null; partiallyAppliedUrlFilterKeyRef.current = null; - if (laneParam || statusParam) stripUrlFilterParams(); + if (sessionParam || laneParam || statusParam) stripUrlFilterParams(); return; } // When the URL specifies a laneId but lanes haven't populated yet (e.g. on @@ -1037,6 +1038,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) return; } if (appliedQuerySessionIdRef.current === sessionParam) return; + if (pendingProjectSwitchRef.current != null) return; const session = sessions.find((entry) => entry.id === sessionParam); if (!session) return; From c0a3aaf0f7b28f58780613ec5c2da0ddeb2f7c38 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:29:33 -0400 Subject: [PATCH 12/80] fix(remote): reset remote work state on project open --- .../remoteConnectionPool.test.ts | 50 ++++++++++ .../remoteRuntime/remoteConnectionPool.ts | 3 + .../components/files/treeHelpers.test.ts | 24 +++++ .../renderer/components/files/treeHelpers.ts | 6 ++ .../components/files/v2/FilesWorkbench.tsx | 9 +- .../src/renderer/state/appStore.test.ts | 80 ++++++++++++++++ apps/desktop/src/renderer/state/appStore.ts | 95 ++++++++++++++----- 7 files changed, 243 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/renderer/components/files/treeHelpers.test.ts diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts index 3c622671c..b26fff9e0 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts @@ -600,6 +600,56 @@ describe("RemoteConnectionPool", () => { }, { timeoutMs: 25_000 }); }); + it("retries file git decoration refresh once after ECONNRESET", async () => { + const firstClient = createClient(); + const firstSsh = createSsh(); + firstClient.call.mockRejectedValueOnce(new Error("read ECONNRESET")); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: firstSsh, + result: connectResult("1.0.0"), + }); + const secondClient = createClient(); + secondClient.call.mockResolvedValueOnce({ + ok: true, + domain: "file", + action: "refreshGitDecorations", + result: { workspaceId: "primary", files: [], directories: [] }, + statusHints: { reconnected: true }, + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + const pool = new RemoteConnectionPool({ get: () => null } as unknown as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "file", + action: "refreshGitDecorations", + args: { workspaceId: "primary", forceFresh: true }, + }), + ).resolves.toEqual({ + domain: "file", + action: "refreshGitDecorations", + result: { workspaceId: "primary", files: [], directories: [] }, + statusHints: { reconnected: true }, + }); + + expect(firstSsh.end).toHaveBeenCalledTimes(1); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(secondClient.call).toHaveBeenCalledWith("ade/actions/call", { + projectId: "project-1", + name: "run_ade_action", + arguments: { + domain: "file", + action: "refreshGitDecorations", + args: { workspaceId: "primary", forceFresh: true }, + }, + }, { timeoutMs: 25_000 }); + }); + it("lists remote ADE actions as grouped registry entries", async () => { const client = createClient(); client.call.mockResolvedValueOnce({ diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts index 13ec75769..2491ce1ac 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts @@ -84,7 +84,10 @@ const RETRYABLE_REMOTE_ACTIONS = new Set([ "chat.codexFuzzyFileSearch", "chat.fileSearch", "chat.modelCatalog", + "file.listTreeChildren", "file.quickOpen", + "file.readFileRange", + "file.refreshGitDecorations", "terminal.activeForChat", "terminal.preview", ]); diff --git a/apps/desktop/src/renderer/components/files/treeHelpers.test.ts b/apps/desktop/src/renderer/components/files/treeHelpers.test.ts new file mode 100644 index 000000000..7b28bdb72 --- /dev/null +++ b/apps/desktop/src/renderer/components/files/treeHelpers.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { isUnavailableGitDecorationsError } from "./treeHelpers"; + +describe("isUnavailableGitDecorationsError", () => { + it("matches optional remote git decoration action availability failures", () => { + expect( + isUnavailableGitDecorationsError( + new Error( + "Error invoking remote method 'ade.remoteRuntime.callAction': Error: Action 'file.refreshGitDecorations' is not callable.", + ), + ), + ).toBe(true); + expect( + isUnavailableGitDecorationsError( + new Error("Action 'file.refreshGitDecorations' is not exposed through ADE actions."), + ), + ).toBe(true); + }); + + it("does not hide unrelated Files errors", () => { + expect(isUnavailableGitDecorationsError(new Error("ENOENT: no such file or directory"))).toBe(false); + expect(isUnavailableGitDecorationsError(new Error("Action 'file.readFile' is not callable."))).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/components/files/treeHelpers.ts b/apps/desktop/src/renderer/components/files/treeHelpers.ts index 20057bd6d..305af80f9 100644 --- a/apps/desktop/src/renderer/components/files/treeHelpers.ts +++ b/apps/desktop/src/renderer/components/files/treeHelpers.ts @@ -39,6 +39,12 @@ export function formatFilesError(err: unknown, fallback = "File operation failed ); } +export function isUnavailableGitDecorationsError(err: unknown): boolean { + const message = formatFilesError(err); + return /file\.refreshGitDecorations/i.test(message) + && /not callable|not exposed|unavailable/i.test(message); +} + export function isMissingWorkspaceRootError(message: string): boolean { return /(?:ENOENT|no such file or directory|worktree is missing|workspace is missing)/i.test(message); } diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx index 39a076d6a..c58296677 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx @@ -12,6 +12,7 @@ import { defaultFilesWorkspaceId, filesSessionKey, formatFilesError, + isUnavailableGitDecorationsError, mergeTreePreservingLoadedChildren, replaceTreeNodeChildren, } from "../treeHelpers"; @@ -168,7 +169,13 @@ export function FilesWorkbench({ return merged; }); setError(null); - const decorations = await window.ade.files.refreshGitDecorations({ workspaceId: reqId, forceFresh: true }); + let decorations = null; + try { + decorations = await window.ade.files.refreshGitDecorations({ workspaceId: reqId, forceFresh: true }); + } catch (decorationError) { + if (!isUnavailableGitDecorationsError(decorationError)) throw decorationError; + } + if (!decorations) return; if (workspaceIdRef.current !== reqId) return; setTree((prev) => { const decorated = applyGitStatusToTree(prev, decorations); diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 5ac8a1619..a50afe5e6 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -1233,5 +1233,85 @@ describe("appStore", () => { expect(useAppStore.getState().project?.rootPath).toBe("/remote/b"); expect(useAppStore.getState().projectBinding).toEqual(bindingB); }); + + it("drops stale project-scoped Work and lane caches when opening a remote project", async () => { + const binding = { + kind: "remote" as const, + key: "remote:target-1:project-a", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-a", + rootPath: "/remote/a", + displayName: "Project A", + }; + const defaultWorkState = useAppStore.getState().getWorkViewState("/remote/a"); + (window.ade.remoteRuntime.openProject as any).mockResolvedValueOnce(binding); + useAppStore.setState({ + project: { rootPath: "/local/project", displayName: "Local", baseRef: "main" } as any, + selectedLaneId: "local-lane", + focusedSessionId: "local-session", + workViewByProject: { + "/remote/a": { + ...defaultWorkState, + openItemIds: ["local-session"], + activeItemId: "local-session", + selectedItemId: "local-session", + }, + "/local/project": { + ...defaultWorkState, + openItemIds: ["local-session"], + activeItemId: "local-session", + }, + }, + laneWorkViewByScope: { + "/remote/a::local-lane": { + ...defaultWorkState, + openItemIds: ["local-session"], + }, + "/local/project::local-lane": { + ...defaultWorkState, + openItemIds: ["local-session"], + }, + }, + laneSelectionByProject: { + "/remote/a": { laneId: "local-lane", sessionId: "local-session" }, + "/local/project": { laneId: "local-lane", sessionId: "local-session" }, + }, + laneCacheByProject: { + "/remote/a": { lanes: [{ id: "local-lane", name: "Local lane" }] as any[], laneSnapshots: [] }, + "/local/project": { lanes: [{ id: "local-lane", name: "Local lane" }] as any[], laneSnapshots: [] }, + }, + sessionsCacheByProject: { + "/remote/a": [{ id: "local-session", laneId: "local-lane" }], + "/local/project": [{ id: "local-session", laneId: "local-lane" }], + }, + } as any); + + await useAppStore.getState().switchRemoteProject("target-1", "project-a"); + + const state = useAppStore.getState(); + expect(state.project).toEqual({ + rootPath: "/remote/a", + displayName: "Project A", + baseRef: "main", + }); + expect(state.selectedLaneId).toBeNull(); + expect(state.focusedSessionId).toBeNull(); + expect(state.workViewByProject["/remote/a"]).toBeUndefined(); + expect(state.laneWorkViewByScope["/remote/a::local-lane"]).toBeUndefined(); + expect(state.laneSelectionByProject["/remote/a"]).toBeUndefined(); + expect(state.laneCacheByProject["/remote/a"]).toBeUndefined(); + expect(state.sessionsCacheByProject["/remote/a"]).toBeUndefined(); + expect(state.workViewByProject["/local/project"]?.openItemIds).toEqual(["local-session"]); + expect(state.laneWorkViewByScope["/local/project::local-lane"]?.openItemIds).toEqual(["local-session"]); + expect(state.laneSelectionByProject["/local/project"]).toEqual({ + laneId: "local-lane", + sessionId: "local-session", + }); + expect(state.laneCacheByProject["/local/project"]?.lanes[0]?.id).toBe("local-lane"); + expect(state.sessionsCacheByProject["/local/project"]).toEqual([ + { id: "local-session", laneId: "local-lane" }, + ]); + }); }); }); diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index a3b6ea61a..1fb297aa3 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -367,6 +367,29 @@ function normalizeLaneWorkScopeKey(projectRoot: string | null | undefined, laneI return `${projectKey}::${normalizedLaneId}`; } +function removeWorkViewStateForProject( + projectRoot: string | null | undefined, + workViewByProject: Record, + laneWorkViewByScope: Record, +): { + workViewByProject: Record; + laneWorkViewByScope: Record; +} { + const projectKey = normalizeProjectKey(projectRoot); + if (!projectKey) return { workViewByProject, laneWorkViewByScope }; + const nextWorkViewByProject = { ...workViewByProject }; + delete nextWorkViewByProject[projectKey]; + const nextLaneWorkViewByScope = { ...laneWorkViewByScope }; + const laneScopePrefix = `${projectKey}::`; + for (const key of Object.keys(nextLaneWorkViewByScope)) { + if (key.startsWith(laneScopePrefix)) delete nextLaneWorkViewByScope[key]; + } + return { + workViewByProject: nextWorkViewByProject, + laneWorkViewByScope: nextLaneWorkViewByScope, + }; +} + type WarmLaneCache = { lanes: LaneSummary[]; laneSnapshots: LaneListSnapshot[] }; function laneCacheStorageKey(projectRoot: string): string { @@ -1673,29 +1696,55 @@ const createAppState: StateCreator = (set, get) => { if (switchGeneration !== remoteProjectSwitchGeneration) { return binding; } - set({ - project: { - rootPath: binding.rootPath, - displayName: binding.displayName, - baseRef: "main", - }, - projectBinding: binding, - projectRevision: get().projectRevision + 1, - projectHydrated: true, - showWelcome: false, - projectTransition: null, - projectTransitionError: null, - isNewTabOpen: false, - laneSnapshots: [], - lanes: [], - lanesLoading: true, - laneDeleteProgressByLaneId: {}, - selectedLaneId: null, - focusedSessionId: null, - laneInspectorTabs: {}, - keybindings: null, - terminalAttention: EMPTY_TERMINAL_ATTENTION, - macosVmTabIndicator: null, + removePersistedLaneCache(binding.rootPath); + set((prev) => { + const projectKey = normalizeProjectKey(binding.rootPath); + const { + workViewByProject, + laneWorkViewByScope, + } = removeWorkViewStateForProject( + projectKey, + prev.workViewByProject, + prev.laneWorkViewByScope, + ); + persistWorkViewState({ workViewByProject, laneWorkViewByScope }); + const laneSelectionByProject = { ...prev.laneSelectionByProject }; + const laneCacheByProject = { ...prev.laneCacheByProject }; + const sessionsCacheByProject = { ...prev.sessionsCacheByProject }; + if (projectKey) { + delete laneSelectionByProject[projectKey]; + delete laneCacheByProject[projectKey]; + delete sessionsCacheByProject[projectKey]; + } + return { + project: { + rootPath: binding.rootPath, + displayName: binding.displayName, + baseRef: "main", + }, + projectBinding: binding, + projectRevision: prev.projectRevision + 1, + projectHydrated: true, + showWelcome: false, + projectTransition: null, + projectTransitionError: null, + isNewTabOpen: false, + laneSnapshots: [], + lanes: [], + lanesLoading: true, + laneDeleteProgressByLaneId: {}, + selectedLaneId: null, + focusedSessionId: null, + laneInspectorTabs: {}, + keybindings: null, + terminalAttention: EMPTY_TERMINAL_ATTENTION, + macosVmTabIndicator: null, + workViewByProject, + laneWorkViewByScope, + laneSelectionByProject, + laneCacheByProject, + sessionsCacheByProject, + }; }); void get().refreshLanes({ includeStatus: false }); return binding; From 486bb822ccb05d69933f3adb6dbbe098d9fae5fe Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:40:05 -0400 Subject: [PATCH 13/80] fix(remote): mount active remote project surfaces --- .../src/renderer/components/app/App.tsx | 5 +++- .../components/app/App.workKeepAlive.test.tsx | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 2367bf036..f8b3809d9 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -669,11 +669,14 @@ function ProjectTabHost() { }, [activeRoot, location]); const projects = React.useMemo(() => { - const roots = openProjectTabRoots.length > 0 + const rootsFromTabs = openProjectTabRoots.length > 0 ? openProjectTabRoots : activeProject?.rootPath ? [activeProject.rootPath] : []; + const roots = activeProject?.rootPath && !rootsFromTabs.includes(activeProject.rootPath) + ? [activeProject.rootPath, ...rootsFromTabs] + : rootsFromTabs; return roots .map((root) => projectInfoByRoot[root] ?? (activeProject?.rootPath === root ? activeProject : null)) .filter((project): project is ProjectInfo => project != null); diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index 3d854952a..ad3677836 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -381,6 +381,31 @@ describe("App Work route keep-alive", () => { expect(lanesLifecycle.unmounts).toBe(0); }); + it("mounts the active remote project even when local project tabs are open", async () => { + appStoreState.project = { rootPath: "/remote/project", displayName: "Remote project" } as any; + appStoreState.openProjectTabRoots = ["/fake/project"]; + appStoreState.projectInfoByRoot = { + "/fake/project": { rootPath: "/fake/project", displayName: "Fake" }, + }; + const { App } = await import("./App"); + + render(); + + await waitFor(() => { + const remoteSurface = screen + .getAllByTestId("work-page") + .find((node) => node.closest("[data-project-root='/remote/project']")); + expect(remoteSurface).toBeTruthy(); + expect(remoteSurface?.closest("[aria-hidden='true']")).toBeNull(); + expect(remoteSurface?.getAttribute("data-active")).toBe("true"); + }); + + const localSurface = screen + .getAllByTestId("work-page") + .find((node) => node.closest("[data-project-root='/fake/project']")); + expect(localSurface?.closest("[aria-hidden='true']")).not.toBeNull(); + }); + it("converts legacy hash app routes into BrowserRouter paths", async () => { window.history.replaceState({}, "", "/work#/lanes"); const { App } = await import("./App"); From 26cac15bcd63c758977f073f1bdea4885189756f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:16:29 -0400 Subject: [PATCH 14/80] fix(lanes): allow clean lane delete without hidden confirm --- .../renderer/components/lanes/LanesPage.tsx | 1 - .../lanes/ManageLaneDialog.test.tsx | 64 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index b9f3d0bea..e1bb70a07 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -1774,7 +1774,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const targets = isBatchManage ? managedLanes : managedLane ? [managedLane] : []; const actionable = targets.filter((l) => l.laneType !== "primary"); if (actionable.length === 0) return; - if (deleteConfirmText.trim().toLowerCase() !== deletePhrase.toLowerCase()) return; const deleteArgsByLaneId = new Map(); for (const lane of actionable) { diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx index 4dfa61fd5..e89822b2b 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx @@ -159,4 +159,68 @@ describe("ManageLaneDialog tabs", () => { expect(screen.getByText("1 chat session")).toBeTruthy(); }); }); + + it("allows a clean worktree-only delete without hidden confirmation text", async () => { + const onDelete = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("tab", { name: "Delete" })); + + const deleteButton = await screen.findByRole("button", { name: /delete lane/i }); + await waitFor(() => { + expect((deleteButton as HTMLButtonElement).disabled).toBe(false); + }); + + fireEvent.click(deleteButton); + + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it("requires typed confirmation for a dirty lane delete", async () => { + (window as any).ade.lanes.getDeleteRisk.mockResolvedValueOnce({ + ...deleteRisk, + dirty: true, + }); + const onDelete = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("tab", { name: "Delete" })); + + const deleteButton = await screen.findByRole("button", { name: /delete lane/i }); + await waitFor(() => { + expect((deleteButton as HTMLButtonElement).disabled).toBe(true); + }); + + rerender( + , + ); + + fireEvent.click(screen.getByRole("tab", { name: "Delete" })); + await waitFor(() => { + expect((screen.getByRole("button", { name: /delete lane/i }) as HTMLButtonElement).disabled).toBe(false); + }); + + fireEvent.click(screen.getByRole("button", { name: /delete lane/i })); + + expect(onDelete).toHaveBeenCalledTimes(1); + }); }); From e87e44307b473fd9ab59ac74ea40df6dd4ff5adc Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:31:47 -0400 Subject: [PATCH 15/80] fix(remote): preserve bindings in project surfaces --- .../src/renderer/components/app/App.tsx | 40 ++++++++++--- .../components/app/App.workKeepAlive.test.tsx | 57 ++++++++++++++++++- .../lanes/CreateLaneDialog.test.tsx | 22 +++++++ .../components/lanes/CreateLaneDialog.tsx | 13 ++++- .../renderer/components/lanes/LanesPage.tsx | 15 +++++ apps/desktop/src/renderer/state/appStore.ts | 21 ++++--- 6 files changed, 148 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index f8b3809d9..d564681fd 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -101,7 +101,7 @@ import { import { getDirtyFileTextForWindow } from "../../lib/dirtyWorkspaceBuffers"; import { getAiStatusCached } from "../../lib/aiDiscoveryCache"; import { dispatchWorkSurfaceRevealed } from "../terminals/workSurfaceVisibility"; -import type { AppNavigationRequest, ProjectInfo } from "../../../shared/types"; +import type { AppNavigationRequest, OpenProjectBinding, ProjectInfo } from "../../../shared/types"; // Use path-based routes on http(s) (Vite in Chrome, Cursor Simple Browser, etc.). // Use hash routes for non-http(s) surfaces (e.g. packaged Electron `file://`) where @@ -251,6 +251,25 @@ const WARM_PROJECT_SURFACE_LIMIT = 8; const EMPTY_PROJECT_TAB_ROOTS: string[] = []; const EMPTY_PROJECT_INFO_BY_ROOT: Record = {}; +function localProjectBindingForProject(project: ProjectInfo): OpenProjectBinding { + return { + kind: "local", + key: `local:${project.rootPath}`, + rootPath: project.rootPath, + displayName: project.displayName, + }; +} + +function bindingForProject( + project: ProjectInfo, + activeProjectBinding: OpenProjectBinding | null, +): OpenProjectBinding { + if (activeProjectBinding?.rootPath === project.rootPath) { + return activeProjectBinding; + } + return localProjectBindingForProject(project); +} + function projectRouteStorageKey(projectRoot: string): string { return `${PROJECT_ROUTE_STORAGE_PREFIX}${projectRoot}`; } @@ -484,11 +503,13 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string function ProjectSurface({ active, project, + projectBinding, route, store, }: { active: boolean; project: ProjectInfo; + projectBinding: OpenProjectBinding; route: string; store: AppStoreApi; }) { @@ -497,16 +518,11 @@ function ProjectSurface({ React.useEffect(() => { hydrateProjectAppStore(store, { project, - projectBinding: { - kind: "local", - key: `local:${project.rootPath}`, - rootPath: project.rootPath, - displayName: project.displayName, - }, + projectBinding, projectHydrated: true, showWelcome: false, }); - }, [project, store]); + }, [project, projectBinding, store]); React.useEffect(() => { if (!active || !isWorkRoutePath(route.split(/[?#]/, 1)[0] || "/work")) return; @@ -568,6 +584,7 @@ function ProjectTabHost() { const location = useLocation(); const navigate = useNavigate(); const activeProject = useAppStore((s) => s.project); + const activeProjectBinding = useAppStore((s) => s.projectBinding); const projectHydrated = useAppStore((s) => s.projectHydrated); const showWelcome = useAppStore((s) => s.showWelcome); const projectTransition = useAppStore((s) => s.projectTransition); @@ -701,7 +718,10 @@ function ProjectTabHost() { for (const project of mountedProjects) { if (!storesRef.current.has(project.rootPath)) { - storesRef.current.set(project.rootPath, createProjectAppStore(project)); + storesRef.current.set(project.rootPath, createProjectAppStore( + project, + bindingForProject(project, activeProjectBinding), + )); } } @@ -748,11 +768,13 @@ function ProjectTabHost() { if (!store) return null; const liveRoute = project.rootPath === activeRoot ? serializeProjectRoute(location) : null; const route = liveRoute ?? routesByRoot[project.rootPath] ?? readStoredProjectRoute(project.rootPath) ?? "/work"; + const projectBinding = bindingForProject(project, activeProjectBinding); return ( diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index ad3677836..5a087ff19 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -20,6 +20,20 @@ const appStoreState = vi.hoisted(() => ({ projectHydrated: true, showWelcome: false, project: { rootPath: "/fake/project" }, + projectBinding: { + kind: "local", + key: "local:/fake/project", + rootPath: "/fake/project", + displayName: "project", + } as { + kind: "local" | "remote"; + key: string; + rootPath: string; + displayName: string; + targetId?: string; + runtimeName?: string; + projectId?: string; + }, projectTransition: null as { kind: "opening" | "switching" | "closing"; rootPath: string | null; startedAtMs: number } | null, theme: "dark", launchPromptClipboardEnabled: true, @@ -42,8 +56,11 @@ vi.mock("../../state/appStore", async () => { }); return { useAppStore: vi.fn((selector: (state: typeof appStoreState) => unknown) => selector(appStoreState)), - createProjectAppStore: vi.fn((project) => { - let state = createScopedState(project); + createProjectAppStore: vi.fn((project, projectBinding) => { + let state = { + ...createScopedState(project), + projectBinding: projectBinding ?? appStoreState.projectBinding, + }; return { getState: () => state, setState: (partial: unknown) => { @@ -169,6 +186,12 @@ describe("App Work route keep-alive", () => { appStoreState.projectHydrated = true; appStoreState.showWelcome = false; appStoreState.project = { rootPath: "/fake/project" }; + appStoreState.projectBinding = { + kind: "local", + key: "local:/fake/project", + rootPath: "/fake/project", + displayName: "project", + }; appStoreState.projectTransition = null; appStoreState.theme = "dark"; appStoreState.launchPromptClipboardEnabled = true; @@ -383,10 +406,20 @@ describe("App Work route keep-alive", () => { it("mounts the active remote project even when local project tabs are open", async () => { appStoreState.project = { rootPath: "/remote/project", displayName: "Remote project" } as any; + appStoreState.projectBinding = { + kind: "remote", + key: "remote:studio:project-1", + targetId: "studio", + runtimeName: "Mac Studio", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Remote project", + }; appStoreState.openProjectTabRoots = ["/fake/project"]; appStoreState.projectInfoByRoot = { "/fake/project": { rootPath: "/fake/project", displayName: "Fake" }, }; + const { createProjectAppStore, hydrateProjectAppStore } = await import("../../state/appStore"); const { App } = await import("./App"); render(); @@ -399,6 +432,26 @@ describe("App Work route keep-alive", () => { expect(remoteSurface?.closest("[aria-hidden='true']")).toBeNull(); expect(remoteSurface?.getAttribute("data-active")).toBe("true"); }); + expect(createProjectAppStore).toHaveBeenCalledWith( + expect.objectContaining({ rootPath: "/remote/project" }), + expect.objectContaining({ + kind: "remote", + runtimeName: "Mac Studio", + rootPath: "/remote/project", + }), + ); + await waitFor(() => { + expect(hydrateProjectAppStore).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + projectBinding: expect.objectContaining({ + kind: "remote", + runtimeName: "Mac Studio", + rootPath: "/remote/project", + }), + }), + ); + }); const localSurface = screen .getAllByTestId("work-page") diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx index b43178c06..edf6d8771 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx @@ -75,6 +75,28 @@ function makeProps(overrides: Partial = {}): DialogProps { } describe("CreateLaneDialog VM-lane gate", () => { + it("describes the default runtime as the local Mac", () => { + render(); + + expect(screen.getByText("Local Mac")).toBeTruthy(); + expect(screen.getByText("Use this ADE runtime and local worktree.")).toBeTruthy(); + }); + + it("uses the provided runtime copy for a remote project", () => { + render( + , + ); + + expect(screen.getByText("Mac Studio")).toBeTruthy(); + expect(screen.getByText("Use this connected remote ADE runtime and remote worktree.")).toBeTruthy(); + expect(screen.queryByText("Use this ADE runtime and local worktree.")).toBeNull(); + }); + it("disables the Mac VM radio and shows the setup CTA when no VM exists", () => { const onOpenVmTab = vi.fn(); const onOpenChange = vi.fn(); diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx index 5336eec94..84ed02577 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx @@ -107,6 +107,8 @@ export function CreateLaneDialog({ existingVmLane = null, onOpenVmTab, onOpenVmLaneInWork, + localRuntimeLabel = "Local Mac", + localRuntimeDescription = "Use this ADE runtime and local worktree.", projectRoot, createBranches, lanes, @@ -158,6 +160,13 @@ export function CreateLaneDialog({ onOpenVmTab?: () => void; /** Open the Work tab on the existing VM lane. Used when a VM lane already exists. */ onOpenVmLaneInWork?: (laneId: string) => void; + /** Label for the non-VM runtime. + * Remote projects still submit `runtimePlacement: "local"`, but this copy + * should name the connected runtime. + */ + localRuntimeLabel?: string; + /** Description for the non-VM runtime card. */ + localRuntimeDescription?: string; /** Project scope for shared Linear issue browser cache/filter persistence. */ projectRoot?: string | null; createBranches: LaneBranchOption[]; @@ -568,11 +577,11 @@ export function CreateLaneDialog({
-
Local Mac
+
{localRuntimeLabel}
- Use this ADE runtime and local worktree. + {localRuntimeDescription}
); } @@ -353,6 +354,33 @@ describe("PrsContext refresh", () => { }); }); + it("keeps workflow PRs usable when queue states are unavailable on the runtime", async () => { + window.location.hash = "#/prs?tab=workflows&workflow=integration"; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(window.ade.prs.listWithConflicts).mockResolvedValue([makeFakePr("pr-1")]); + vi.mocked(window.ade.prs.listQueueStates).mockRejectedValue( + new Error("action 'pr.listQueueStates' is not callable"), + ); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + expect(window.ade.prs.listWithConflicts).toHaveBeenCalledWith({ includeConflictAnalysis: true }); + expect(window.ade.lanes.list).toHaveBeenCalledWith({ includeStatus: false }); + expect(screen.getByTestId("queue-count").textContent).toBe("0"); + expect(warnSpy).toHaveBeenCalledWith( + "[PrsContext] Failed to load workflow queue states:", + expect.any(Error), + ); + warnSpy.mockRestore(); + }); + it("runs a GitHub PR refresh for explicit refresh actions", async () => { const user = userEvent.setup(); diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index c75b01ed1..57f2b998e 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -218,6 +218,15 @@ function readPrsContextWarmCache(projectRoot?: string | null): PrsContextWarmCac return prsContextWarmCacheByProject.get(prsContextCacheKey(projectRoot)) ?? null; } +async function listWorkflowQueueStates(): Promise { + try { + return await window.ade.prs.listQueueStates({ includeCompleted: true, limit: 50 }); + } catch (err) { + console.warn("[PrsContext] Failed to load workflow queue states:", err); + return []; + } +} + function readBoolLs(key: string, fallback: boolean): boolean { try { const raw = localStorage.getItem(key); @@ -814,7 +823,7 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi window.ade.prs.listWithConflicts({ includeConflictAnalysis: shouldLoadWorkflowState }), window.ade.lanes.list({ includeStatus: false }), shouldLoadWorkflowState - ? window.ade.prs.listQueueStates({ includeCompleted: true, limit: 50 }) + ? listWorkflowQueueStates() : Promise.resolve([] as QueueLandingState[]), ]); const changedPrIds = diffPrIds(prsRef.current, prList); @@ -1493,16 +1502,13 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi useEffect(() => { if (!active || activeTab === "normal") return; let cancelled = false; - window.ade.prs.listQueueStates({ includeCompleted: true, limit: 50 }) + listWorkflowQueueStates() .then((states) => { if (cancelled) return; setQueueStates((prev) => { const next = Object.fromEntries(states.map((state) => [state.groupId, state] as const)); return jsonEqual(prev, next) ? prev : next; }); - }) - .catch((err) => { - console.warn("[PrsContext] Failed to load workflow queue states:", err); }); return () => { cancelled = true; From 44489d500e1d4ea47ec4c8e01ed998c1c8e472ab Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:46:11 -0400 Subject: [PATCH 18/80] fix(files): respect read-only workspaces --- .../components/files/FilesExplorer.test.tsx | 64 +++++++++++++++++++ .../components/files/FilesExplorer.tsx | 8 +++ .../components/files/v2/FilesWorkbench.tsx | 29 ++++++--- 3 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/renderer/components/files/FilesExplorer.test.tsx diff --git a/apps/desktop/src/renderer/components/files/FilesExplorer.test.tsx b/apps/desktop/src/renderer/components/files/FilesExplorer.test.tsx new file mode 100644 index 000000000..15d847fe8 --- /dev/null +++ b/apps/desktop/src/renderer/components/files/FilesExplorer.test.tsx @@ -0,0 +1,64 @@ +/* @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FilesExplorer, type FilesExplorerProps } from "./FilesExplorer"; + +afterEach(cleanup); + +function renderExplorer(overrides: Partial = {}) { + const props: FilesExplorerProps = { + tree: [], + expanded: new Set(), + loadingDirectories: new Set(), + selectedNodePath: null, + activeTabPath: null, + activeContextDir: "", + workspaceComparisonRoot: null, + searchQuery: "", + inlineRenameRequest: null, + onSearchQueryChange: vi.fn(), + singleRowHeader: true, + onCreateFile: vi.fn(), + onCreateDirectory: vi.fn(), + onToggleDirectory: vi.fn(), + onOpenFile: vi.fn(), + onSelectNode: vi.fn(), + onContextMenu: vi.fn(), + onRenamePath: vi.fn(async () => undefined), + onInlineRenameSettled: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +describe("FilesExplorer mutating controls", () => { + it("disables create controls for read-only workspaces", () => { + const props = renderExplorer({ canMutate: false }); + + const newFile = screen.getByLabelText("New file") as HTMLButtonElement; + const newFolder = screen.getByLabelText("New folder") as HTMLButtonElement; + expect(newFile.disabled).toBe(true); + expect(newFolder.disabled).toBe(true); + + fireEvent.click(newFile); + fireEvent.click(newFolder); + expect(props.onCreateFile).not.toHaveBeenCalled(); + expect(props.onCreateDirectory).not.toHaveBeenCalled(); + }); + + it("keeps create controls active for mutable workspaces", () => { + const props = renderExplorer({ canMutate: true }); + + const newFile = screen.getByLabelText("New file") as HTMLButtonElement; + const newFolder = screen.getByLabelText("New folder") as HTMLButtonElement; + expect(newFile.disabled).toBe(false); + expect(newFolder.disabled).toBe(false); + + fireEvent.click(newFile); + fireEvent.click(newFolder); + expect(props.onCreateFile).toHaveBeenCalledWith(""); + expect(props.onCreateDirectory).toHaveBeenCalledWith(""); + }); +}); diff --git a/apps/desktop/src/renderer/components/files/FilesExplorer.tsx b/apps/desktop/src/renderer/components/files/FilesExplorer.tsx index f041323bb..76f8b616b 100644 --- a/apps/desktop/src/renderer/components/files/FilesExplorer.tsx +++ b/apps/desktop/src/renderer/components/files/FilesExplorer.tsx @@ -77,6 +77,7 @@ export type FilesExplorerProps = { onContextMenu: (event: FilesExplorerContextMenuEvent) => void; onRenamePath: (sourcePath: string, destinationPath: string) => Promise; onInlineRenameSettled: () => void; + canMutate?: boolean; compact?: boolean; }; @@ -172,6 +173,7 @@ export function FilesExplorer({ onContextMenu, onRenamePath, onInlineRenameSettled, + canMutate = true, compact = false, }: FilesExplorerProps) { const scrollRef = useRef(null); @@ -282,6 +284,7 @@ export function FilesExplorer({ type="button" title="New file" aria-label="New file" + disabled={!canMutate} style={{ ...outlineButton({ height: 24, padding: "0 6px", fontSize: 10 }) }} onClick={() => onCreateFile(activeContextDir)} onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} @@ -295,6 +298,7 @@ export function FilesExplorer({ type="button" title="New folder" aria-label="New folder" + disabled={!canMutate} style={{ ...outlineButton({ height: 24, padding: "0 6px", fontSize: 10 }) }} onClick={() => onCreateDirectory(activeContextDir)} onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} @@ -357,6 +361,7 @@ export function FilesExplorer({ type="button" title="New file" aria-label="New file" + disabled={!canMutate} style={{ ...outlineButton({ height: 28, padding: "0 7px", fontSize: 10 }) }} onClick={() => onCreateFile(activeContextDir)} onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} @@ -370,6 +375,7 @@ export function FilesExplorer({ type="button" title="New folder" aria-label="New folder" + disabled={!canMutate} style={{ ...outlineButton({ height: 28, padding: "0 7px", fontSize: 10 }) }} onClick={() => onCreateDirectory(activeContextDir)} onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} @@ -458,6 +464,7 @@ export function FilesExplorer({ type="button" title="New file" aria-label="New file" + disabled={!canMutate} style={{ ...outlineButton({ height: 24, padding: "0 6px", fontSize: 10 }) }} onClick={() => onCreateFile(activeContextDir)} onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} @@ -471,6 +478,7 @@ export function FilesExplorer({ type="button" title="New folder" aria-label="New folder" + disabled={!canMutate} style={{ ...outlineButton({ height: 24, padding: "0 6px", fontSize: 10 }) }} onClick={() => onCreateDirectory(activeContextDir)} onMouseEnter={(event) => { event.currentTarget.style.borderColor = COLORS.accent; event.currentTarget.style.color = COLORS.accent; }} diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx index c58296677..e90de565c 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx @@ -442,18 +442,26 @@ export function FilesWorkbench({ const renamePath = useCallback( async (sourcePath: string, destinationPath: string) => { if (!workspaceId) return; + if (!canEdit) { + setError("This workspace is read-only."); + return; + } await window.ade.files.rename({ workspaceId, oldPath: sourcePath, newPath: destinationPath }).catch((err) => { setError(err instanceof Error ? err.message : String(err)); }); closeOpenTabsUnder(sourcePath); // old path/tabs are stale after rename await refreshRoot(); }, - [workspaceId, refreshRoot, closeOpenTabsUnder], + [workspaceId, canEdit, refreshRoot, closeOpenTabsUnder], ); const deletePath = useCallback( async (path: string) => { if (!workspaceId) return; + if (!canEdit) { + setError("This workspace is read-only."); + return; + } const ok = window.confirm(`Delete "${path}"? This cannot be undone.`); if (!ok) return; try { @@ -464,7 +472,7 @@ export function FilesWorkbench({ setError(err instanceof Error ? err.message : String(err)); } }, - [workspaceId, refreshRoot, closeOpenTabsUnder], + [workspaceId, canEdit, refreshRoot, closeOpenTabsUnder], ); const dirForNode = (menu: FilesExplorerContextMenuEvent): string => @@ -479,20 +487,24 @@ export function FilesWorkbench({ items.push({ type: "item", label: "Open", onClick: () => void openFile(path, { preview: false }) }); items.push({ type: "separator" }); } - items.push({ type: "item", label: "New File…", icon: , onClick: () => setOverlay({ kind: "create", create: "file", baseDir }) }); - items.push({ type: "item", label: "New Folder…", icon: , onClick: () => setOverlay({ kind: "create", create: "directory", baseDir }) }); + items.push({ type: "item", label: "New File…", icon: , onClick: () => setOverlay({ kind: "create", create: "file", baseDir }), disabled: !canEdit }); + items.push({ type: "item", label: "New Folder…", icon: , onClick: () => setOverlay({ kind: "create", create: "directory", baseDir }), disabled: !canEdit }); items.push({ type: "separator" }); - items.push({ type: "item", label: "Rename…", icon: , onClick: () => setInlineRename({ path, nonce: ++renameNonceRef.current }) }); - items.push({ type: "item", label: "Delete", icon: , danger: true, onClick: () => void deletePath(path) }); + items.push({ type: "item", label: "Rename…", icon: , onClick: () => setInlineRename({ path, nonce: ++renameNonceRef.current }), disabled: !canEdit }); + items.push({ type: "item", label: "Delete", icon: , danger: true, onClick: () => void deletePath(path), disabled: !canEdit }); items.push({ type: "separator" }); items.push({ type: "item", label: "Copy Path", icon: , onClick: () => void window.ade.app.writeClipboardText?.(path) }); items.push({ type: "item", label: "Reveal in Finder", icon: , onClick: () => void window.ade.app.openPathInEditor?.({ rootPath, relativePath: path, target: "finder" }).catch(() => {}) }); return items; - }, [treeMenu, openFile, deletePath, rootPath]); + }, [treeMenu, openFile, canEdit, deletePath, rootPath]); const createInWorkspace = useCallback( async (kind: "file" | "directory", baseDir: string, name: string) => { if (!workspaceId) return; + if (!canEdit) { + setError("This workspace is read-only."); + return; + } const rel = baseDir ? `${baseDir}/${name}` : name; try { if (kind === "file") await window.ade.files.createFile({ workspaceId, path: rel }); @@ -503,7 +515,7 @@ export function FilesWorkbench({ setError(err instanceof Error ? err.message : String(err)); } }, - [workspaceId, refreshRoot, openFile], + [workspaceId, canEdit, refreshRoot, openFile], ); // Files-scoped keybindings: ⌘P / ⌘⇧F both open the unified in-depth search. @@ -579,6 +591,7 @@ export function FilesWorkbench({ onContextMenu={setTreeMenu} onRenamePath={renamePath} onInlineRenameSettled={() => setInlineRename(null)} + canMutate={canEdit} compact={embedded} /> From 58961685a2f992039bf18d9b14c9c5d380f69c4c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:57:09 -0400 Subject: [PATCH 19/80] fix(files): forget stale recent paths --- .../components/files/v2/FilesWorkbench.tsx | 15 +++++++---- .../components/files/v2/recentFiles.test.ts | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/renderer/components/files/v2/recentFiles.test.ts diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx index e90de565c..37fee3d65 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx @@ -32,7 +32,7 @@ import { } from "./editorGroupsStore"; import { resolveViewerKind } from "./viewerRegistry"; import { invalidateFileContent, primeFileContent } from "./useFileContent"; -import { getRecentFiles, recordRecentFile } from "./recentFiles"; +import { forgetRecentFile, getRecentFiles, recordRecentFile } from "./recentFiles"; import { EditorGroups } from "./EditorGroups"; import { StatusBar } from "./StatusBar"; import { WarmEmptyState } from "./WarmEmptyState"; @@ -446,13 +446,17 @@ export function FilesWorkbench({ setError("This workspace is read-only."); return; } - await window.ade.files.rename({ workspaceId, oldPath: sourcePath, newPath: destinationPath }).catch((err) => { + try { + await window.ade.files.rename({ workspaceId, oldPath: sourcePath, newPath: destinationPath }); + } catch (err) { setError(err instanceof Error ? err.message : String(err)); - }); + return; + } + forgetRecentFile(sessionKey, sourcePath); closeOpenTabsUnder(sourcePath); // old path/tabs are stale after rename await refreshRoot(); }, - [workspaceId, canEdit, refreshRoot, closeOpenTabsUnder], + [workspaceId, canEdit, sessionKey, refreshRoot, closeOpenTabsUnder], ); const deletePath = useCallback( @@ -466,13 +470,14 @@ export function FilesWorkbench({ if (!ok) return; try { await window.ade.files.delete({ workspaceId, path }); + forgetRecentFile(sessionKey, path); closeOpenTabsUnder(path); await refreshRoot(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }, - [workspaceId, canEdit, refreshRoot, closeOpenTabsUnder], + [workspaceId, canEdit, sessionKey, refreshRoot, closeOpenTabsUnder], ); const dirForNode = (menu: FilesExplorerContextMenuEvent): string => diff --git a/apps/desktop/src/renderer/components/files/v2/recentFiles.test.ts b/apps/desktop/src/renderer/components/files/v2/recentFiles.test.ts new file mode 100644 index 000000000..dd6ef3479 --- /dev/null +++ b/apps/desktop/src/renderer/components/files/v2/recentFiles.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { forgetRecentFile, getRecentFiles, recordRecentFile } from "./recentFiles"; + +describe("recentFiles", () => { + it("removes stale paths without disturbing other recents", () => { + const sessionKey = "recent-files-test-remove"; + + recordRecentFile(sessionKey, "README.md"); + recordRecentFile(sessionKey, "deleted.txt"); + recordRecentFile(sessionKey, "src/index.ts"); + + forgetRecentFile(sessionKey, "deleted.txt"); + + expect(getRecentFiles(sessionKey)).toEqual(["src/index.ts", "README.md"]); + }); + + it("keeps recents unique and most-recent first", () => { + const sessionKey = "recent-files-test-order"; + + recordRecentFile(sessionKey, "README.md"); + recordRecentFile(sessionKey, "src/index.ts"); + recordRecentFile(sessionKey, "README.md"); + + expect(getRecentFiles(sessionKey)).toEqual(["README.md", "src/index.ts"]); + }); +}); From 99d0f097060ed40c13a25763dbb3b94b44f614b0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:21:45 -0400 Subject: [PATCH 20/80] fix(remote): tunnel preview URLs locally --- .../src/main/services/ipc/ipcTimeouts.test.ts | 4 + .../src/main/services/ipc/ipcTimeouts.ts | 1 + .../main/services/ipc/runtimeBridge.test.ts | 48 +++++ .../src/main/services/ipc/runtimeBridge.ts | 37 ++++ .../remoteConnectionPool.test.ts | 112 +++++++++++ .../remoteRuntime/remoteConnectionPool.ts | 181 ++++++++++++++++++ .../remoteRuntime/remoteConnectionService.ts | 11 ++ apps/desktop/src/preload/preload.ts | 51 ++++- .../src/renderer/components/app/App.tsx | 49 +++++ .../components/app/App.workKeepAlive.test.tsx | 33 ++++ apps/desktop/src/shared/ipc.ts | 1 + .../desktop/src/shared/types/remoteRuntime.ts | 18 ++ 12 files changed, 540 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts index 0db2d599b..b22a77f40 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts @@ -45,6 +45,10 @@ describe("ipcInvokeTimeoutMs", () => { }])).toBe(30_000); }); + it("lets remote port forwarding include a cold SSH/runtime bind", () => { + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeEnsurePortForward)).toBe(10 * 60_000); + }); + it("keeps iOS launch timeout separate from macOS VM provisioning", () => { expect(ipcInvokeTimeoutMs(IPC.iosSimulatorLaunch)).toBe(10 * 60_000); expect(ipcInvokeTimeoutMs(IPC.macosVmProvision)).toBe(120 * 60_000); diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts index 787e65e96..e2d83a0b7 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts @@ -108,6 +108,7 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [ case IPC.remoteRuntimeOpenProject: case IPC.remoteRuntimeListActionRegistry: case IPC.remoteRuntimeCallSync: + case IPC.remoteRuntimeEnsurePortForward: case IPC.remoteRuntimeStreamEvents: case IPC.remoteRuntimeCheckLocalWork: return REMOTE_RUNTIME_BOOTSTRAP_TIMEOUT_MS; diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 8f3817f37..aedf80429 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -19,6 +19,7 @@ const remoteConnectMock = vi.hoisted(() => vi.fn()); const remoteProjectsForTargetMock = vi.hoisted(() => vi.fn()); const remoteCallActionForTargetMock = vi.hoisted(() => vi.fn()); const remoteCallSyncForTargetMock = vi.hoisted(() => vi.fn()); +const remoteEnsureLocalPortForwardMock = vi.hoisted(() => vi.fn()); const remoteListActionRegistryForTargetMock = vi.hoisted(() => vi.fn()); const remoteCallMachineForTargetMock = vi.hoisted(() => vi.fn()); const remoteDisconnectMock = vi.hoisted(() => vi.fn()); @@ -82,6 +83,7 @@ vi.mock("../remoteRuntime/remoteConnectionPool", () => ({ projectsForTarget: remoteProjectsForTargetMock, callActionForTarget: remoteCallActionForTargetMock, callSyncForTarget: remoteCallSyncForTargetMock, + ensureLocalPortForward: remoteEnsureLocalPortForwardMock, listActionRegistryForTarget: remoteListActionRegistryForTargetMock, callMachineForTarget: remoteCallMachineForTargetMock, disconnect: remoteDisconnectMock, @@ -163,6 +165,7 @@ describe("registerRuntimeBridge", () => { remoteProjectsForTargetMock.mockReset(); remoteCallActionForTargetMock.mockReset(); remoteCallSyncForTargetMock.mockReset(); + remoteEnsureLocalPortForwardMock.mockReset(); remoteListActionRegistryForTargetMock.mockReset(); remoteCallMachineForTargetMock.mockReset(); remoteDisconnectMock.mockReset(); @@ -565,6 +568,51 @@ describe("registerRuntimeBridge", () => { ); }); + it("creates remote port forwards through the selected target", async () => { + remoteRegistryGetMock.mockReturnValue(target); + remoteEnsureLocalPortForwardMock.mockResolvedValue({ + targetId: "target-1", + remoteHost: "127.0.0.1", + remotePort: 3000, + localHost: "127.0.0.1", + localPort: 49152, + localUrl: "http://127.0.0.1:49152", + label: "preview", + createdAt: 1, + lastUsedAt: 1, + }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeEnsurePortForward)?.( + eventForSender(sender(202)), + { + id: " target-1 ", + request: { + remoteHost: " 127.0.0.1 ", + remotePort: 3000, + label: " preview ", + }, + }, + ), + ).resolves.toMatchObject({ + localUrl: "http://127.0.0.1:49152", + }); + + expect(remoteConnectMock).toHaveBeenCalledWith(target); + expect(remoteEnsureLocalPortForwardMock).toHaveBeenCalledWith( + target.id, + { + remoteHost: "127.0.0.1", + remotePort: 3000, + label: "preview", + }, + ); + }); + it("forwards remote project action registry listing through the selected target and project", async () => { const registry = [ { domain: "chat", actions: [{ name: "codexOpenInCli" }] }, diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 85440ef89..fc1ddf301 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -21,6 +21,8 @@ import type { RemoteRuntimeDiscoveryResult, RemoteRuntimeEventNotificationPayload, RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimePortForward, + RemoteRuntimePortForwardRequest, RemoteRuntimeProjectRecord, RemoteRuntimeProjectWorkSummary, RemoteRuntimeSshHostKeyTrustStatus, @@ -741,6 +743,41 @@ export function registerRuntimeBridge({ }, ); + ipcMain.handle( + IPC.remoteRuntimeEnsurePortForward, + async ( + _event, + arg: { + id: string; + request: RemoteRuntimePortForwardRequest; + }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const request = + arg?.request && + typeof arg.request === "object" && + !Array.isArray(arg.request) + ? arg.request + : null; + const remotePort = Number(request?.remotePort); + const remoteHost = + typeof request?.remoteHost === "string" + ? request.remoteHost.trim() + : null; + const label = + typeof request?.label === "string" ? request.label.trim() : null; + if (!id) throw new Error("Remote target id is required."); + if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65_535) { + throw new Error("Remote port must be an integer from 1 to 65535."); + } + return await remoteConnectionService.ensurePortForward(id, { + remoteHost, + remotePort, + label, + }); + }, + ); + ipcMain.handle( IPC.remoteRuntimeCallSync, async ( diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts index b26fff9e0..2e00a7697 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts @@ -1,4 +1,5 @@ import type { Client } from "ssh2"; +import net from "node:net"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RemoteRuntimeConnectResult, @@ -40,6 +41,7 @@ type FakeSshClient = Client & { emitOnce(event: "close" | "error", ...args: unknown[]): void; destroy: ReturnType; end: ReturnType; + forwardOut: ReturnType; once: ReturnType; }; @@ -130,10 +132,22 @@ function createSsh(): FakeSshClient { emitOnce?: FakeSshClient["emitOnce"]; destroy?: ReturnType; end?: ReturnType; + forwardOut?: ReturnType; once?: ReturnType; }; fake.destroy = vi.fn(); fake.end = vi.fn(); + fake.forwardOut = vi.fn(( + _sourceHost: string, + _sourcePort: number, + destinationHost: string, + destinationPort: number, + callback: (error: Error | undefined, stream?: net.Socket) => void, + ) => { + const stream = net.createConnection({ host: destinationHost, port: destinationPort }); + stream.once("connect", () => callback(undefined, stream)); + stream.once("error", (error) => callback(error)); + }); fake.once = vi.fn((event: string, callback: SshListener): FakeSshClient => { const existing = listeners.get(event) ?? []; existing.push(callback); @@ -150,6 +164,31 @@ function createSsh(): FakeSshClient { return fake as unknown as FakeSshClient; } +function listen(server: net.Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("server did not bind to a TCP port")); + return; + } + resolve(address.port); + }); + }); +} + +function closeServer(server: net.Server): Promise { + return new Promise((resolve) => { + if (!server.listening) { + resolve(); + return; + } + server.close(() => resolve()); + }); +} + describe("RemoteConnectionPool", () => { beforeEach(() => { bootstrapRemoteRuntimeMock.mockReset(); @@ -275,6 +314,79 @@ describe("RemoteConnectionPool", () => { expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); }); + it("opens a reusable local SSH forward and closes it on disconnect", async () => { + const upstream = net.createServer((socket) => { + socket.once("data", (chunk) => { + socket.end(`remote:${chunk.toString("utf8")}`); + }); + }); + const upstreamPort = await listen(upstream); + const client = createClient(); + const ssh = createSsh(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client, + ssh, + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + try { + await pool.connect(target); + const [firstForward, secondForward] = await Promise.all([ + pool.ensureLocalPortForward(target.id, { + remotePort: upstreamPort, + label: "preview", + }), + pool.ensureLocalPortForward(target.id, { + remotePort: upstreamPort, + label: "preview", + }), + ]); + + expect(secondForward.localPort).toBe(firstForward.localPort); + expect(ssh.forwardOut).not.toHaveBeenCalled(); + + const response = await new Promise((resolve, reject) => { + const socket = net.createConnection({ + host: firstForward.localHost, + port: firstForward.localPort, + }); + socket.once("connect", () => socket.write("ok")); + socket.once("data", (chunk) => { + resolve(chunk.toString("utf8")); + socket.end(); + }); + socket.once("error", reject); + }); + + expect(response).toBe("remote:ok"); + expect(ssh.forwardOut).toHaveBeenCalledWith( + "127.0.0.1", + 0, + "127.0.0.1", + upstreamPort, + expect.any(Function), + ); + + pool.disconnect(target.id); + await Promise.resolve(); + await expect(new Promise((resolve, reject) => { + const socket = net.createConnection({ + host: firstForward.localHost, + port: firstForward.localPort, + }); + socket.once("connect", () => { + socket.destroy(); + resolve(); + }); + socket.once("error", reject); + })).rejects.toBeTruthy(); + } finally { + pool.dispose(); + await closeServer(upstream); + } + }); + it("connects before streaming events and reconnects after disconnect", async () => { const firstClient = createClient(); firstClient.call.mockResolvedValueOnce({ diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts index 2491ce1ac..cc5f661da 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts @@ -1,4 +1,5 @@ import { app } from "electron"; +import net from "node:net"; import type { Client } from "ssh2"; import type { RemoteRuntimeActionRequest, @@ -6,6 +7,8 @@ import type { RemoteRuntimeBufferedEvent, RemoteRuntimeConnectResult, RemoteRuntimeEventCategory, + RemoteRuntimePortForward, + RemoteRuntimePortForwardRequest, RemoteRuntimeMachineProjectCapability, RemoteRuntimeStreamEventsRequest, RemoteRuntimeStreamEventsResult, @@ -24,6 +27,10 @@ type PoolEntry = { dispose?: (closeClient: boolean, notify?: boolean) => void; }; +type LocalPortForwardEntry = RemoteRuntimePortForward & { + server: net.Server; +}; + function closePoolEntryResources( entry: PoolEntry, closeClient: boolean, @@ -66,6 +73,7 @@ function isRemoteRuntimeConnectionError(error: unknown): boolean { const RETRYABLE_REMOTE_ACTION_RPC_TIMEOUT_MS = 25_000; const CONNECT_FAILURE_BASE_BACKOFF_MS = 3_000; const CONNECT_FAILURE_MAX_BACKOFF_MS = 15_000; +const LOCAL_FORWARD_HOST = "127.0.0.1"; const RETRYABLE_REMOTE_ACTION_PREFIXES = [ "diagnosticsGet", @@ -143,6 +151,48 @@ function remoteRuntimeActionCallOptions( : undefined; } +function normalizeForwardRemoteHost(value: string | null | undefined): string { + const trimmed = typeof value === "string" ? value.trim() : ""; + return trimmed || "127.0.0.1"; +} + +function normalizeForwardPort(value: unknown): number { + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(`Remote port must be an integer from 1 to 65535 (received ${String(value)}).`); + } + return port; +} + +function portForwardKey(targetId: string, remoteHost: string, remotePort: number): string { + return `${targetId}\0${remoteHost}\0${remotePort}`; +} + +function snapshotPortForward(entry: LocalPortForwardEntry): RemoteRuntimePortForward { + const { + targetId, + remoteHost, + remotePort, + localHost, + localPort, + localUrl, + label, + createdAt, + lastUsedAt, + } = entry; + return { + targetId, + remoteHost, + remotePort, + localHost, + localPort, + localUrl, + label, + createdAt, + lastUsedAt, + }; +} + function assertMachineProjectCapability(entry: PoolEntry, method: string): void { const capability = MACHINE_PROJECT_METHOD_CAPABILITY.get(method); if (!capability) return; @@ -156,6 +206,10 @@ function assertMachineProjectCapability(entry: PoolEntry, method: string): void export class RemoteConnectionPool { private readonly entries = new Map>(); + private readonly localPortForwards = new Map< + string, + Promise + >(); private readonly pendingDisconnects = new Set(); private readonly resolvedEntryPromises = new Set>(); private readonly connectFailureBackoffByTargetId = new Map< @@ -313,6 +367,114 @@ export class RemoteConnectionPool { ); } + async ensureLocalPortForward( + targetId: string, + request: RemoteRuntimePortForwardRequest, + ): Promise { + const remoteHost = normalizeForwardRemoteHost(request.remoteHost); + const remotePort = normalizeForwardPort(request.remotePort); + const key = portForwardKey(targetId, remoteHost, remotePort); + const existing = this.localPortForwards.get(key); + if (existing) { + const entry = await existing; + entry.lastUsedAt = Date.now(); + return snapshotPortForward(entry); + } + + const pending = (async (): Promise => { + const entry = await this.requireEntry(targetId); + const createdAt = Date.now(); + const label = + typeof request.label === "string" && request.label.trim() + ? request.label.trim() + : null; + const server = net.createServer((socket) => { + const activeEntry = this.entries.get(targetId); + if (!activeEntry) { + socket.destroy(new Error(`Remote target is not connected: ${targetId}`)); + return; + } + entry.ssh.forwardOut( + LOCAL_FORWARD_HOST, + 0, + remoteHost, + remotePort, + (error, stream) => { + if (error) { + socket.destroy(error); + return; + } + const closeBoth = () => { + try { + socket.destroy(); + } catch {} + try { + stream.destroy(); + } catch {} + }; + socket.once("error", closeBoth); + stream.once("error", closeBoth); + socket.once("close", closeBoth); + stream.once("close", closeBoth); + socket.pipe(stream).pipe(socket); + }, + ); + }); + + return await new Promise((resolve, reject) => { + let settled = false; + const rejectStart = (error: Error) => { + if (settled) return; + settled = true; + try { + server.close(); + } catch {} + reject(error); + }; + server.once("error", rejectStart); + server.listen(0, LOCAL_FORWARD_HOST, () => { + if (settled) return; + server.off("error", rejectStart); + const address = server.address(); + if (!address || typeof address === "string") { + rejectStart(new Error("Local remote-preview forward did not bind to a TCP port.")); + return; + } + settled = true; + const localPort = address.port; + const forward: LocalPortForwardEntry = { + targetId, + remoteHost, + remotePort, + localHost: LOCAL_FORWARD_HOST, + localPort, + localUrl: `http://${LOCAL_FORWARD_HOST}:${localPort}`, + label, + createdAt, + lastUsedAt: createdAt, + server, + }; + resolve(forward); + }); + }); + })(); + this.localPortForwards.set(key, pending); + try { + const forward = await pending; + forward.server.once("close", () => { + if (this.localPortForwards.get(key) === pending) { + this.localPortForwards.delete(key); + } + }); + return snapshotPortForward(forward); + } catch (error) { + if (this.localPortForwards.get(key) === pending) { + this.localPortForwards.delete(key); + } + throw error; + } + } + async callSyncForTarget( target: RemoteRuntimeTarget, projectId: string, @@ -544,6 +706,7 @@ export class RemoteConnectionPool { } disconnect(targetId: string): void { + this.closeLocalPortForwardsForTarget(targetId); const existing = this.entries.get(targetId); if (!existing) return; if (this.resolvedEntryPromises.has(existing)) { @@ -584,11 +747,28 @@ export class RemoteConnectionPool { } dispose(): void { + for (const targetId of [...this.entries.keys()]) { + this.closeLocalPortForwardsForTarget(targetId); + } for (const targetId of [...this.entries.keys()]) { this.disconnect(targetId); } } + private closeLocalPortForwardsForTarget(targetId: string): void { + for (const [key, pending] of [...this.localPortForwards.entries()]) { + if (!key.startsWith(`${targetId}\0`)) continue; + this.localPortForwards.delete(key); + void pending + .then((entry) => { + try { + entry.server.close(); + } catch {} + }) + .catch(() => {}); + } + } + private async requireEntry(targetId: string): Promise { const entry = this.entries.get(targetId); if (!entry) throw new Error(`Remote target is not connected: ${targetId}`); @@ -644,6 +824,7 @@ export class RemoteConnectionPool { this.resolvedEntryPromises.delete(entryPromise); if (cleanedUp) return; cleanedUp = true; + this.closeLocalPortForwardsForTarget(targetId); closePoolEntryResources(entry, closeClient, true); if (notify) notifyEvicted(error); }; diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts index e1e609b5a..ca56b55ef 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts @@ -11,6 +11,8 @@ import type { RemoteRuntimeConnectionState, RemoteRuntimeConnectionStatus, RemoteRuntimeConnectResult, + RemoteRuntimePortForward, + RemoteRuntimePortForwardRequest, RemoteRuntimeProjectRecord, RemoteRuntimeActionRequest, RemoteRuntimeActionResult, @@ -255,6 +257,15 @@ export class RemoteConnectionService { } } + async ensurePortForward( + targetId: string, + request: RemoteRuntimePortForwardRequest, + ): Promise { + const target = this.requireTarget(targetId); + await this.pool.connect(target); + return await this.pool.ensureLocalPortForward(target.id, request); + } + async browseDirectories( targetId: string, input: ProjectBrowseInput, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index dfc413257..1f6b7632f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -659,6 +659,7 @@ import type { RemoteRuntimeDiscoveryResult, RemoteRuntimeEventNotificationPayload, RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimePortForward, RemoteRuntimeProjectRecord, RemoteRuntimeSshHostKeyTrustStatus, RemoteRuntimeStreamEventsRequest, @@ -1137,6 +1138,33 @@ async function callRemoteProjectActionIfBound( return { handled: true, result: response.result as T }; } +function isValidPreviewTargetPort(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535; +} + +async function localizeRemoteLanePreviewInfo( + binding: Extract, + info: LanePreviewInfo | null, +): Promise { + if (!info) return null; + if (!isValidPreviewTargetPort(info.targetPort)) return info; + const forward = (await ipcRenderer.invoke(IPC.remoteRuntimeEnsurePortForward, { + id: binding.targetId, + request: { + remoteHost: "127.0.0.1", + remotePort: info.targetPort, + label: `${binding.displayName}:${info.laneId}`, + }, + })) as RemoteRuntimePortForward; + return { + ...info, + hostname: forward.localHost, + previewUrl: forward.localUrl, + proxyPort: forward.localPort, + active: true, + }; +} + async function callLocalProjectActionIfBound( domain: string, action: string, @@ -4679,13 +4707,24 @@ contextBridge.exposeInMainWorld("ade", { }, proxyGetPreviewInfo: async ( args: GetPreviewInfoArgs, - ): Promise => - callProjectRuntimeActionOr("lane", "proxyGetPreviewInfo", { args }, () => - ipcRenderer.invoke(IPC.lanesProxyGetPreviewInfo, args), - ), + ): Promise => { + const binding = await getProjectRuntimeBinding(); + if (binding?.kind === "remote") { + const runtime = + await callProjectRuntimeActionIfBound( + "lane", + "proxyGetPreviewInfo", + { args }, + ); + if (runtime.handled) { + return localizeRemoteLanePreviewInfo(binding, runtime.result); + } + } + return ipcRenderer.invoke(IPC.lanesProxyGetPreviewInfo, args); + }, proxyOpenPreview: async (args: OpenPreviewArgs): Promise => { const binding = await getProjectRuntimeBinding(); - if (binding) { + if (binding?.kind === "remote") { const runtime = await callProjectRuntimeActionIfBound( "lane", @@ -4696,7 +4735,7 @@ contextBridge.exposeInMainWorld("ade", { await ipcRenderer.invoke(IPC.lanesProxyOpenPreview, args); return; } - const info = runtime.result; + const info = await localizeRemoteLanePreviewInfo(binding, runtime.result); if (!info) throw new Error(`No preview route for lane: ${args.laneId}`); await ipcRenderer.invoke(IPC.appOpenExternal, { url: info.previewUrl }); return; diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 04c10f7ec..13776fe53 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -101,6 +101,7 @@ import { import { getDirtyFileTextForWindow } from "../../lib/dirtyWorkspaceBuffers"; import { getAiStatusCached } from "../../lib/aiDiscoveryCache"; import { dispatchWorkSurfaceRevealed } from "../terminals/workSurfaceVisibility"; +import { ADE_OPEN_BUILT_IN_BROWSER_EVENT } from "../../lib/openExternal"; import type { AppNavigationRequest, OpenProjectBinding, ProjectInfo } from "../../../shared/types"; // Use path-based routes on http(s) (Vite in Chrome, Cursor Simple Browser, etc.). @@ -314,6 +315,23 @@ function writeStoredProjectRoute(projectRoot: string, route: string): void { } } +function browserEventProjectRoot(value: unknown): string | null | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + if ("profileProjectRoot" in record) { + const root = record.profileProjectRoot; + return typeof root === "string" && root.trim().length > 0 ? root : null; + } + return browserEventProjectRoot(record.status); +} + +function browserEventMatchesProject(event: unknown, projectRoot: string | null): boolean { + const root = browserEventProjectRoot(event); + if (root === undefined) return projectRoot == null; + if (!projectRoot) return root === null; + return root === projectRoot; +} + function projectNameFromRoot(rootPath: string | null | undefined): string | null { if (!rootPath) return null; const segments = rootPath.split(/[\\/]/).filter(Boolean); @@ -340,6 +358,9 @@ function ProjectTransitionVeil({ label }: { label: string }) { } function ProjectRouteContent({ active, route }: { active: boolean; route: string }) { + const navigate = useNavigate(); + const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const setWorkViewState = useAppStore((s) => s.setWorkViewState); const workSurfaceRef = React.useRef(null); const lanesSurfaceRef = React.useRef(null); const isWorkRoute = isWorkRoutePath(route.split(/[?#]/, 1)[0] || "/work"); @@ -364,6 +385,34 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string setLanesRoute(route); }, [isLanesRoute, route]); + React.useEffect(() => { + if (!active || !projectRoot) return; + const revealWorkBrowser = () => { + setWorkViewState(projectRoot, { + viewMode: "tabs", + workSidebarOpen: true, + workSidebarTab: "browser", + }); + if (!isWorkRoute) navigate("/work"); + }; + const handleBrowserEvent = (event: unknown) => { + if ( + event + && typeof event === "object" + && (event as { type?: unknown }).type === "open-request" + && browserEventMatchesProject(event, projectRoot) + ) { + revealWorkBrowser(); + } + }; + window.addEventListener(ADE_OPEN_BUILT_IN_BROWSER_EVENT, revealWorkBrowser); + const unsubscribeBrowserEvents = window.ade?.builtInBrowser?.onEvent?.(handleBrowserEvent) ?? null; + return () => { + window.removeEventListener(ADE_OPEN_BUILT_IN_BROWSER_EVENT, revealWorkBrowser); + unsubscribeBrowserEvents?.(); + }; + }, [active, isWorkRoute, navigate, projectRoot, setWorkViewState]); + React.useEffect(() => { const node = workSurfaceRef.current; if (!node) return; diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index 5a087ff19..712445b95 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -5,6 +5,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type * as ReactNamespace from "react"; import type * as RouterNamespace from "react-router-dom"; +import { ADE_OPEN_BUILT_IN_BROWSER_EVENT } from "../../lib/openExternal"; const workLifecycle = vi.hoisted(() => ({ mounts: 0, @@ -38,6 +39,12 @@ const appStoreState = vi.hoisted(() => ({ theme: "dark", launchPromptClipboardEnabled: true, launchPromptClipboardNoticeEnabled: true, + workViewByProject: {} as Record>, + setWorkViewState: vi.fn((projectRoot: string | null | undefined, next: Record) => { + if (!projectRoot) return; + const current = appStoreState.workViewByProject[projectRoot] ?? {}; + appStoreState.workViewByProject[projectRoot] = { ...current, ...next }; + }), openProjectTabRoots: [] as string[], projectInfoByRoot: {} as Record, })); @@ -196,6 +203,8 @@ describe("App Work route keep-alive", () => { appStoreState.theme = "dark"; appStoreState.launchPromptClipboardEnabled = true; appStoreState.launchPromptClipboardNoticeEnabled = true; + appStoreState.workViewByProject = {}; + appStoreState.setWorkViewState.mockClear(); appStoreState.openProjectTabRoots = []; appStoreState.projectInfoByRoot = {}; window.localStorage.clear(); @@ -238,6 +247,30 @@ describe("App Work route keep-alive", () => { expect(workLifecycle.unmounts).toBe(0); }); + it("reveals the Work browser pane when an ADE browser URL opens from another tab", async () => { + window.history.replaceState({}, "", "/files"); + const { App } = await import("./App"); + + render(); + + await screen.findByTestId("files-page"); + fireEvent(window, new CustomEvent(ADE_OPEN_BUILT_IN_BROWSER_EVENT, { + detail: { url: "http://127.0.0.1:64054" }, + })); + + await waitFor(() => { + expect(window.location.pathname).toBe("/work"); + }); + expect(appStoreState.setWorkViewState).toHaveBeenCalledWith( + "/fake/project", + expect.objectContaining({ + viewMode: "tabs", + workSidebarOpen: true, + workSidebarTab: "browser", + }), + ); + }); + it("hydrates project stores with launch clipboard reminder preferences", async () => { appStoreState.launchPromptClipboardNoticeEnabled = false; const { hydrateProjectAppStore } = await import("../../state/appStore"); diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 57c191c91..57e77dfd1 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -66,6 +66,7 @@ export const IPC = { remoteRuntimeListActionRegistry: "ade.remoteRuntime.listActionRegistry", remoteRuntimeCallAction: "ade.remoteRuntime.callAction", remoteRuntimeCallSync: "ade.remoteRuntime.callSync", + remoteRuntimeEnsurePortForward: "ade.remoteRuntime.ensurePortForward", remoteRuntimeStreamEvents: "ade.remoteRuntime.streamEvents", remoteRuntimeCheckLocalWork: "ade.remoteRuntime.checkLocalWork", remoteRuntimeDisconnect: "ade.remoteRuntime.disconnect", diff --git a/apps/desktop/src/shared/types/remoteRuntime.ts b/apps/desktop/src/shared/types/remoteRuntime.ts index 2a35a4659..abded4cdd 100644 --- a/apps/desktop/src/shared/types/remoteRuntime.ts +++ b/apps/desktop/src/shared/types/remoteRuntime.ts @@ -80,6 +80,24 @@ export type RemoteRuntimeConnectResult = { projects: RemoteRuntimeProjectRecord[]; }; +export type RemoteRuntimePortForwardRequest = { + remoteHost?: string | null; + remotePort: number; + label?: string | null; +}; + +export type RemoteRuntimePortForward = { + targetId: string; + remoteHost: string; + remotePort: number; + localHost: string; + localPort: number; + localUrl: string; + label: string | null; + createdAt: number; + lastUsedAt: number; +}; + export type RemoteRuntimeSshHostKeyIdentity = { targetId: string; host: string; From 33a485baad46e960027289d6eb23aa979bc04597 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:36:46 -0400 Subject: [PATCH 21/80] fix(work): reduce remote session fanout --- .../components/chat/AgentChatPane.tsx | 30 +++++++- .../components/chat/ChatGitToolbar.tsx | 5 +- .../chat/ChatTerminalDrawer.test.tsx | 15 ++++ .../components/chat/ChatTerminalDrawer.tsx | 3 +- .../components/terminals/SessionCard.test.tsx | 76 ++++++++++++++++++- .../components/terminals/SessionCard.tsx | 4 +- .../components/terminals/useWorkSessions.ts | 11 ++- 7 files changed, 131 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 953e5b316..c4327e7a9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -2338,6 +2338,7 @@ export function AgentChatPane({ }) { const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); const projectTransition = useAppStore((s) => s.projectTransition); + const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); const agentTurnCompletionSoundVolume = useAppStore((s) => s.agentTurnCompletionSoundVolume); const agentTurnCompletionSoundQuietWhenFocused = useAppStore((s) => s.agentTurnCompletionSoundQuietWhenFocused); @@ -2740,6 +2741,7 @@ export function AgentChatPane({ const api = window.ade?.appControl; if (!api?.getStatus) return; if (!laneToolsVisible) return; + if (isRemoteProject && !effectiveAppControlOpen) return; let cancelled = false; void api.getStatus() .then((status) => { @@ -2753,7 +2755,7 @@ export function AgentChatPane({ return () => { cancelled = true; }; - }, [laneToolsVisible]); + }, [effectiveAppControlOpen, isRemoteProject, laneToolsVisible]); useEffect(() => { companionHydrationKeyRef.current = companionStateKey; @@ -4637,6 +4639,10 @@ export function AgentChatPane({ setComputerUseSnapshot(null); return; } + if (isRemoteProject && !(chatActionsOpen && chatActionsTab === "proof")) { + setComputerUseSnapshot(null); + return; + } if (!lockedSingleSessionMode) { void refreshComputerUseSnapshot(selectedSessionId); return; @@ -4645,7 +4651,15 @@ export function AgentChatPane({ void refreshComputerUseSnapshot(selectedSessionId); }, 180); return () => window.clearTimeout(handle); - }, [isTileActive, lockedSingleSessionMode, refreshComputerUseSnapshot, selectedSessionId]); + }, [ + chatActionsOpen, + chatActionsTab, + isRemoteProject, + isTileActive, + lockedSingleSessionMode, + refreshComputerUseSnapshot, + selectedSessionId, + ]); useEffect(() => { setPromptSuggestion(null); @@ -4942,6 +4956,9 @@ export function AgentChatPane({ useEffect(() => { if (!isTileActive) return undefined; + if (isRemoteProject && !(chatActionsOpen && chatActionsTab === "proof")) { + return undefined; + } const unsubscribe = window.ade.computerUse.onEvent((event) => { if (!selectedSessionId) return; if (event.owner?.kind === "chat_session" && event.owner.id === selectedSessionId) { @@ -4949,7 +4966,14 @@ export function AgentChatPane({ } }); return unsubscribe; - }, [isTileActive, refreshComputerUseSnapshot, selectedSessionId]); + }, [ + chatActionsOpen, + chatActionsTab, + isRemoteProject, + isTileActive, + refreshComputerUseSnapshot, + selectedSessionId, + ]); useEffect(() => { if (!selectedSessionId) { diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx index 506488d2c..91305077f 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx @@ -117,10 +117,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ const refreshStatus = useCallback(async () => { try { - const [, changes] = await Promise.all([ - window.ade.git.listBranches({ laneId }), - window.ade.diff.getChanges({ laneId }), - ]); + const changes = await window.ade.diff.getChanges({ laneId }); setDirtyCount(dirtyFileCount(changes)); } catch { // best-effort diff --git a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx index b9bd46097..d3c41b7f9 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx @@ -98,6 +98,21 @@ describe("ChatTerminalDrawer", () => { expect(onToggle).toHaveBeenCalledTimes(2); }); + it("does not restore terminal tabs while the drawer is closed", async () => { + render( + , + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(window.ade.terminal.list).not.toHaveBeenCalled(); + }); + it("switches restored terminal tabs", async () => { vi.mocked(window.ade.terminal.list).mockResolvedValueOnce([ { diff --git a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx index c3c2e8151..a08242541 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx @@ -250,6 +250,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ useEffect(() => { if (!chatSessionId) return; + if (!open && !revealRequest) return; let cancelled = false; setRestoringTabs(true); window.ade.terminal.list({ chatSessionId, limit: 20 }) @@ -285,7 +286,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ return () => { cancelled = true; }; - }, [chatSessionId, uiStateKey]); + }, [chatSessionId, open, revealRequest, uiStateKey]); useEffect(() => { if (!revealRequest) return; diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index f5222574f..cc1d88a6f 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -2,12 +2,18 @@ import React from "react"; import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; +import { useAppStore } from "../../state/appStore"; import { SessionCard } from "./SessionCard"; +const useSessionDeltaMock = vi.hoisted(() => + vi.fn((_sessionId: string | null, _enabled: boolean) => null), +); + vi.mock("./useSessionDelta", () => ({ - useSessionDelta: () => null, + useSessionDelta: (sessionId: string | null, enabled: boolean) => + useSessionDeltaMock(sessionId, enabled), })); function makeSession(overrides: Partial = {}): TerminalSessionSummary { @@ -43,6 +49,18 @@ const lane = { archivedAt: null, } as LaneSummary; +beforeEach(() => { + useSessionDeltaMock.mockClear(); + useAppStore.setState({ + projectBinding: { + kind: "local", + key: "local:/tmp/project", + rootPath: "/tmp/project", + displayName: "project", + }, + }); +}); + describe("SessionCard orchestration identity", () => { it("uses the orchestration role as the primary sidebar label", () => { render( @@ -64,4 +82,58 @@ describe("SessionCard orchestration identity", () => { expect(screen.getByText("WORKER · ui")).toBeTruthy(); expect(screen.getByRole("button", { name: /Worker · ui: Build the plan panel/ })).toBeTruthy(); }); + + it("defers remote delta loading until a session card is selected", () => { + useAppStore.setState({ + projectBinding: { + kind: "remote", + key: "remote:target:project", + targetId: "target", + runtimeName: "Mac Studio", + projectId: "project", + rootPath: "/Users/admin/Projects/perf pass", + displayName: "perf pass", + }, + }); + + render( + , + ); + + expect(useSessionDeltaMock).toHaveBeenCalledWith("session-1", false); + }); + + it("loads deltas for the selected remote session card", () => { + useAppStore.setState({ + projectBinding: { + kind: "remote", + key: "remote:target:project", + targetId: "target", + runtimeName: "Mac Studio", + projectId: "project", + rootPath: "/Users/admin/Projects/perf pass", + displayName: "perf pass", + }, + }); + + render( + , + ); + + expect(useSessionDeltaMock).toHaveBeenCalledWith("session-1", true); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index 282df406b..b3bce4202 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -9,6 +9,7 @@ import { preferredSessionLabel, } from "../../lib/sessions"; import { relativeTimeCompact } from "../../lib/format"; +import { useAppStore } from "../../state/appStore"; import { useSessionDelta } from "./useSessionDelta"; import { cn } from "../ui/cn"; import { MONO_FONT } from "../lanes/laneDesignTokens"; @@ -137,7 +138,8 @@ export const SessionCard = React.memo(function SessionCard({ compact?: boolean; }) { const dot = sessionStatusDot(session); - const delta = useSessionDelta(session.id, true); + const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); + const delta = useSessionDelta(session.id, !isRemoteProject || isSelected); const primaryText = primarySessionLabel(session); const previewLine = getPreviewLine(session, primaryText); const staleAgeHours = getStaleRunningCliSessionAgeHours(session); diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index 90dd29dbf..0dded4c8f 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -344,6 +344,9 @@ type UseWorkSessionsOptions = { active?: boolean; }; +const LOCAL_RUNNING_SESSION_REFRESH_INTERVAL_MS = 5_000; +const REMOTE_RUNNING_SESSION_REFRESH_INTERVAL_MS = 15_000; + export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) { const navigate = useNavigate(); const location = useLocation(); @@ -1073,11 +1076,15 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) if (event.projectRoot && event.projectRoot !== currentProjectRoot) return; markSessionListDirtyOrRefresh(120); }); + const remoteProject = useAppStore.getState().projectBinding?.kind === "remote"; + const intervalMs = remoteProject + ? REMOTE_RUNNING_SESSION_REFRESH_INTERVAL_MS + : LOCAL_RUNNING_SESSION_REFRESH_INTERVAL_MS; const t = setInterval(() => { if (document.visibilityState !== "visible") return; if (!hasRunningSessionsRef.current) return; scheduleBackgroundRefresh(180); - }, 5_000); + }, intervalMs); return () => { try { unsubExit(); @@ -1086,7 +1093,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) } clearInterval(t); }; - }, [isWorkRoute, markSessionListDirtyOrRefresh, scheduleBackgroundRefresh]); + }, [isWorkRoute, markSessionListDirtyOrRefresh, projectRoot, scheduleBackgroundRefresh]); useEffect(() => { if (!isWorkRoute) return; From b3546c1ead4b37b0180818e58cfe7bb7127e611f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:42:56 -0400 Subject: [PATCH 22/80] fix(work): defer model recents hydration --- .../shared/ModelPicker/ModelPicker.tsx | 2 +- .../ModelPicker/useModelRecents.test.tsx | 81 +++++++++++++++++++ .../shared/ModelPicker/useModelRecents.ts | 17 +++- 3 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.test.tsx diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index 894427540..b3548a9e0 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -84,7 +84,7 @@ export const ModelPicker = memo(function ModelPicker({ const [open, setOpen] = useState(false); const [runtimeCatalog, setRuntimeCatalog] = useState(() => getSharedRuntimeCatalog()); const [refreshingProvider, setRefreshingProvider] = useState(null); - const { recents } = useModelRecents(); + const { recents } = useModelRecents({ hydrate: open }); const loadRuntimeCatalog = useCallback(async (args: { mode: "cached" | "refresh-stale" | "force"; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.test.tsx new file mode 100644 index 000000000..09fd32bc0 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.test.tsx @@ -0,0 +1,81 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { __resetModelRecentsForTests, useModelRecents } from "./useModelRecents"; + +const getRecents = vi.fn<[], Promise<{ recents: string[] }>>(); +const pushRecent = vi.fn<[string], Promise<{ recents: string[] }>>(); + +describe("useModelRecents", () => { + beforeEach(() => { + window.localStorage.clear(); + getRecents.mockResolvedValue({ recents: ["remote/model"] }); + pushRecent.mockImplementation(async (modelId) => ({ recents: [modelId] })); + window.ade = { + modelPicker: { + getRecents, + pushRecent, + }, + } as unknown as typeof window.ade; + __resetModelRecentsForTests(); + }); + + afterEach(() => { + Reflect.deleteProperty(window, "ade"); + window.localStorage.clear(); + vi.clearAllMocks(); + __resetModelRecentsForTests(); + }); + + it("uses cached recents without hydrating when disabled", async () => { + window.localStorage.setItem("ade.modelPicker.recents.v1", JSON.stringify(["cached/model"])); + __resetModelRecentsForTests(); + + const { result } = renderHook(() => useModelRecents({ hydrate: false })); + + expect(result.current.recents).toEqual(["cached/model"]); + await Promise.resolve(); + expect(getRecents).not.toHaveBeenCalled(); + }); + + it("hydrates from the runtime when enabled", async () => { + const { result } = renderHook(() => useModelRecents({ hydrate: true })); + + await waitFor(() => { + expect(result.current.recents).toEqual(["remote/model"]); + }); + expect(getRecents).toHaveBeenCalledTimes(1); + }); + + it("hydrates after the option changes from disabled to enabled", async () => { + const { result, rerender } = renderHook( + ({ hydrate }) => useModelRecents({ hydrate }), + { initialProps: { hydrate: false } }, + ); + + expect(result.current.recents).toEqual([]); + expect(getRecents).not.toHaveBeenCalled(); + + rerender({ hydrate: true }); + + await waitFor(() => { + expect(result.current.recents).toEqual(["remote/model"]); + }); + expect(getRecents).toHaveBeenCalledTimes(1); + }); + + it("records usage optimistically even when hydration is disabled", async () => { + const { result } = renderHook(() => useModelRecents({ hydrate: false })); + + act(() => { + result.current.recordUsage("local/model"); + }); + + expect(result.current.recents).toEqual(["local/model"]); + expect(pushRecent).toHaveBeenCalledWith("local/model"); + await waitFor(() => { + expect(window.localStorage.getItem("ade.modelPicker.recents.v1")).toBe(JSON.stringify(["local/model"])); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts index d38609a13..6ae4fb3dc 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts @@ -101,11 +101,23 @@ const useRecentsStore = create((set, get) => ({ }, })); -export function useModelRecents(): { +type UseModelRecentsOptions = { + hydrate?: boolean; +}; + +export function __resetModelRecentsForTests(): void { + useRecentsStore.setState({ + recents: typeof window !== "undefined" ? readPersisted() : [], + hydrated: false, + }); +} + +export function useModelRecents(options: UseModelRecentsOptions = {}): { recents: string[]; recordUsage: (modelId: string) => void; recordRecent: (modelId: string) => void; } { + const hydrate = options.hydrate ?? true; const { recents, recordUsage, hydrateFromRemote, hydrated } = useRecentsStore( useShallow((state) => ({ recents: state.recents, @@ -116,9 +128,10 @@ export function useModelRecents(): { ); useEffect(() => { + if (!hydrate) return; if (hydrated) return; void hydrateFromRemote(); - }, [hydrateFromRemote, hydrated]); + }, [hydrate, hydrateFromRemote, hydrated]); return { recents, recordUsage, recordRecent: recordUsage }; } From d5f0a30a2cb75887e5ccf78dec6a2764d50fc685 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:54:31 -0400 Subject: [PATCH 23/80] fix(work): lazy load chat file workspaces --- .../chat/AgentChatMessageList.test.tsx | 56 +++++++++++-------- .../components/chat/AgentChatMessageList.tsx | 20 ------- 2 files changed, 32 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 05c42e977..a474d23db 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -863,9 +863,7 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click( screen.getByRole("button", { @@ -873,6 +871,9 @@ describe("AgentChatMessageList transcript rendering", () => { }), ); + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); expect(screen.getByTestId("location").textContent).toBe( "/files::{\"openFilePath\":\"apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx\",\"laneId\":\"lane-123\"}", ); @@ -908,12 +909,13 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "C:\\Users\\me\\repo\\src\\main.ts" })); + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); expect(screen.getByTestId("location").textContent).toBe( "/files::{\"openFilePath\":\"src/main.ts\",\"laneId\":\"lane-win\"}", ); @@ -949,12 +951,13 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "c:\\users\\me\\repo\\src\\main.ts" })); + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); expect(screen.getByTestId("location").textContent).toBe( "/files::{\"openFilePath\":\"src/main.ts\",\"laneId\":\"lane-win\"}", ); @@ -990,12 +993,13 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "main.ts" })); + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); expect(screen.getByTestId("location").textContent).toBe( "/files::{\"openFilePath\":\"src/main.ts\",\"laneId\":\"lane-win\"}", ); @@ -1031,12 +1035,13 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "C:\\Users\\me\\repo\\src\\main.ts:42:5" })); + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); expect(screen.getByTestId("location").textContent).toBe( "/files::{\"openFilePath\":\"src/main.ts\",\"laneId\":\"lane-win\",\"startLine\":42,\"startColumn\":5}", ); @@ -1072,12 +1077,13 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "C:\\Users\\me\\repo\\src\\..\\main.ts:42" })); + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); expect(screen.getByTestId("location").textContent).toBe( "/files::{\"openFilePath\":\"main.ts\",\"laneId\":\"lane-win\",\"startLine\":42}", ); @@ -1113,12 +1119,13 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "\\\\server\\share\\repo\\src\\main.ts" })); + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); expect(screen.getByTestId("location").textContent).toBe( "/files::{\"openFilePath\":\"src/main.ts\",\"laneId\":\"lane-unc\"}", ); @@ -1154,12 +1161,13 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - await waitFor(() => { - expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); - }); + expect(globalThis.window.ade.files.listWorkspaces).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "file://server/share/repo/src/main.ts#line=12" })); + await waitFor(() => { + expect(globalThis.window.ade.files.listWorkspaces).toHaveBeenCalledTimes(1); + }); expect(screen.getByTestId("location").textContent).toBe( "/files::{\"openFilePath\":\"src/main.ts\",\"laneId\":\"lane-unc\",\"startLine\":12}", ); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 5eefb7d85..762bc29a1 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -4168,26 +4168,6 @@ function AgentChatMessageListMain({ ? (location.state as { laneId: string }).laneId : null; - useEffect(() => { - let cancelled = false; - const listWorkspaces = window.ade?.files?.listWorkspaces; - if (typeof listWorkspaces !== "function") return; - listWorkspaces() - .then((workspaces) => { - if (!cancelled) { - setFilesWorkspaces(workspaces); - } - }) - .catch(() => { - if (!cancelled) { - setFilesWorkspaces([]); - } - }); - return () => { - cancelled = true; - }; - }, []); - const openWorkspacePath = useCallback(async (path: string | WorkspacePathLocation) => { let resolvedWorkspaces = filesWorkspaces; let target = resolveFilesNavigationTarget({ From af9b8b9b2d46694922effde6d0d5afa5197fcc5c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:56:42 -0400 Subject: [PATCH 24/80] fix(work): defer app control drawer status --- .../src/renderer/components/chat/ChatTerminalDrawer.test.tsx | 1 + .../src/renderer/components/chat/ChatTerminalDrawer.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx index d3c41b7f9..4ce26c9b2 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx @@ -111,6 +111,7 @@ describe("ChatTerminalDrawer", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(window.ade.terminal.list).not.toHaveBeenCalled(); + expect(window.ade.appControl.getStatus).not.toHaveBeenCalled(); }); it("switches restored terminal tabs", async () => { diff --git a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx index a08242541..35dc8b138 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx @@ -418,6 +418,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ }, []); useEffect(() => { + if (!open) return undefined; const appControlBridge = window.ade?.appControl; if (!appControlBridge) return undefined; let cancelled = false; @@ -438,7 +439,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ cancelled = true; unsubscribe(); }; - }, []); + }, [open]); const closeTab = useCallback((tabId: string) => { const entry = tabsRef.current.find((tab) => tab.id === tabId); From c5f01fbea367be6b96584183410244ba8820d6f4 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:01:20 -0400 Subject: [PATCH 25/80] fix(remote): skip hidden linear checks --- .../components/app/LinearQuickViewButton.tsx | 21 ++++++++++++------- .../renderer/components/app/TopBar.test.tsx | 20 +++++------------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx index ecd325409..926254f0a 100644 --- a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx +++ b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx @@ -134,6 +134,7 @@ export function LinearQuickViewButton({ const batchConfigByIssueRef = useRef>(new Map()); const activeProjectRoot = projectBinding?.kind === "remote" ? projectBinding.rootPath : project?.rootPath; + const shouldAutoCheckVisibility = variant === "icon" && projectBinding?.kind !== "remote"; const visibilityRetryIntervalMs = projectBinding?.kind === "remote" ? REMOTE_VISIBILITY_RETRY_INTERVAL_MS @@ -190,10 +191,14 @@ export function LinearQuickViewButton({ useEffect(() => { if (variant !== "icon") return; - let cancelled = false; setVisible(false); setOpen(false); setQuickView(null); + }, [activeProjectRoot, variant]); + + useEffect(() => { + if (!shouldAutoCheckVisibility) return; + let cancelled = false; const timer = window.setTimeout(() => { void loadVisibility() .then((nextVisible) => { @@ -207,10 +212,10 @@ export function LinearQuickViewButton({ cancelled = true; window.clearTimeout(timer); }; - }, [loadVisibility, activeProjectRoot, variant]); + }, [loadVisibility, shouldAutoCheckVisibility]); useEffect(() => { - if (variant !== "icon") return; + if (!shouldAutoCheckVisibility) return; let timer: number | null = null; let cancelled = false; const onBridge = () => { @@ -236,10 +241,10 @@ export function LinearQuickViewButton({ if (timer != null) window.clearTimeout(timer); window.removeEventListener("ade:runtime-bridge-ready", onBridge); }; - }, [loadVisibility, variant]); + }, [loadVisibility, shouldAutoCheckVisibility]); useEffect(() => { - if (variant !== "icon") return; + if (!shouldAutoCheckVisibility) return; if (!activeProjectRoot) return; let cancelled = false; const refresh = () => { @@ -256,10 +261,10 @@ export function LinearQuickViewButton({ cancelled = true; window.removeEventListener("focus", refresh); }; - }, [loadVisibility, activeProjectRoot, variant]); + }, [loadVisibility, activeProjectRoot, shouldAutoCheckVisibility]); useEffect(() => { - if (variant !== "icon") return; + if (!shouldAutoCheckVisibility) return; if (visible) return; if (!activeProjectRoot) return; let cancelled = false; @@ -275,7 +280,7 @@ export function LinearQuickViewButton({ cancelled = true; window.clearInterval(interval); }; - }, [loadVisibility, visible, activeProjectRoot, visibilityRetryIntervalMs, variant]); + }, [loadVisibility, visible, activeProjectRoot, visibilityRetryIntervalMs, shouldAutoCheckVisibility]); const openQuickView = useCallback(() => { if (cachedQuickViewRef.current) { diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 037ba049d..e3a654f94 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -1041,7 +1041,7 @@ describe("TopBar", () => { } }); - it("paces hidden Linear quick view retries on remote projects", async () => { + it("does not run automatic hidden Linear checks on remote projects", async () => { vi.useFakeTimers(); useAppStore.setState({ project: null, @@ -1074,22 +1074,12 @@ describe("TopBar", () => { render(); await act(async () => { - vi.advanceTimersByTime(8_000); - await flushMicrotasks(2); - }); - expect(getLinearConnectionStatus).toHaveBeenCalledTimes(1); - - await act(async () => { - vi.advanceTimersByTime(6_000); - await flushMicrotasks(2); - }); - expect(getLinearConnectionStatus).toHaveBeenCalledTimes(1); - - await act(async () => { - vi.advanceTimersByTime(1_000); + window.dispatchEvent(new Event("ade:runtime-bridge-ready")); + window.dispatchEvent(new Event("focus")); + vi.advanceTimersByTime(30_000); await flushMicrotasks(2); }); - expect(getLinearConnectionStatus).toHaveBeenCalledTimes(2); + expect(getLinearConnectionStatus).not.toHaveBeenCalled(); expect(screen.queryByRole("button", { name: /linear quick view/i })).toBeNull(); } finally { vi.useRealTimers(); From b6801a1b83a14abf28f72bcb0ca36bf69efe4350 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:04:22 -0400 Subject: [PATCH 26/80] fix(work): share project config boot cache --- .../src/renderer/components/chat/AgentChatPane.test.tsx | 3 +++ apps/desktop/src/renderer/components/chat/AgentChatPane.tsx | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 030519dda..22e2a64c9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -18,6 +18,7 @@ import type { import { createDynamicCursorCliModelDescriptor, getModelById } from "../../../shared/modelRegistry"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { DRAFT_LAUNCH_JOB_STALE_AFTER_MS } from "../../lib/draftLaunchJobs"; +import { invalidateProjectConfigCache } from "../../lib/projectConfigCache"; import { useAppStore } from "../../state/appStore"; import { rememberRuntimeCatalog, @@ -684,6 +685,7 @@ function installMatchMediaMock(): void { beforeEach(() => { installMatchMediaMock(); invalidateAiDiscoveryCache(); + invalidateProjectConfigCache(); resetModelPickerRuntimeCatalogForTests(); window.localStorage.clear(); window.sessionStorage.clear(); @@ -712,6 +714,7 @@ beforeEach(() => { afterEach(() => { cleanup(); invalidateAiDiscoveryCache(); + invalidateProjectConfigCache(); resetModelPickerRuntimeCatalogForTests(); Object.defineProperty(window.navigator, "platform", { configurable: true, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c4327e7a9..dc2232425 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -137,6 +137,7 @@ import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { WorkSurfaceHeader } from "../work/WorkSurfaceHeader"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; import { getAgentChatModelsCached, getAiStatusCached, invalidateAiDiscoveryCache, peekAiStatusCached } from "../../lib/aiDiscoveryCache"; +import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; import { isDraftLaunchJobStale, @@ -4358,7 +4359,7 @@ export function AgentChatPane({ setLoading(!hasRenderableSession); setPreferencesReady(false); try { - const snapshot = await window.ade.projectConfig.get(); + const snapshot = await getProjectConfigCached({ projectRoot }); const chat = snapshot.effective.ai?.chat; if (!cancelled) { // Don't auto-restore model — user must pick one explicitly each session From 52c6261f24c4080bed7d5c9152c01b6140d9e0cf Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:07:55 -0400 Subject: [PATCH 27/80] fix(work): cache chat slash commands --- .../components/chat/AgentChatPane.test.tsx | 3 + .../components/chat/AgentChatPane.tsx | 12 +++- .../lib/agentChatSlashCommandsCache.test.ts | 70 +++++++++++++++++++ .../lib/agentChatSlashCommandsCache.ts | 61 ++++++++++++++++ 4 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/agentChatSlashCommandsCache.test.ts create mode 100644 apps/desktop/src/renderer/lib/agentChatSlashCommandsCache.ts diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 22e2a64c9..c67790726 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -16,6 +16,7 @@ import type { TerminalSessionDetail, } from "../../../shared/types"; import { createDynamicCursorCliModelDescriptor, getModelById } from "../../../shared/modelRegistry"; +import { invalidateAgentChatSlashCommandsCache } from "../../lib/agentChatSlashCommandsCache"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { DRAFT_LAUNCH_JOB_STALE_AFTER_MS } from "../../lib/draftLaunchJobs"; import { invalidateProjectConfigCache } from "../../lib/projectConfigCache"; @@ -684,6 +685,7 @@ function installMatchMediaMock(): void { beforeEach(() => { installMatchMediaMock(); + invalidateAgentChatSlashCommandsCache(); invalidateAiDiscoveryCache(); invalidateProjectConfigCache(); resetModelPickerRuntimeCatalogForTests(); @@ -713,6 +715,7 @@ beforeEach(() => { afterEach(() => { cleanup(); + invalidateAgentChatSlashCommandsCache(); invalidateAiDiscoveryCache(); invalidateProjectConfigCache(); resetModelPickerRuntimeCatalogForTests(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index dc2232425..9d99e3b3d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -136,6 +136,7 @@ import { import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { WorkSurfaceHeader } from "../work/WorkSurfaceHeader"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; +import { getAgentChatSlashCommandsCached } from "../../lib/agentChatSlashCommandsCache"; import { getAgentChatModelsCached, getAiStatusCached, invalidateAiDiscoveryCache, peekAiStatusCached } from "../../lib/aiDiscoveryCache"; import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; @@ -4703,7 +4704,7 @@ export function AgentChatPane({ const args = selectedSessionId ? { sessionId: selectedSessionId } : { laneId, provider: sessionProvider }; - window.ade.agentChat.slashCommands(args) + getAgentChatSlashCommandsCached(args) .then((cmds) => { if (!cancelled) setSdkSlashCommands(cmds); }) .catch(() => { if (!cancelled) setSdkSlashCommands([]); }); return () => { cancelled = true; }; @@ -4946,7 +4947,12 @@ export function AgentChatPane({ if (shouldRefreshSlashCommands) { if (envelope.sessionId === selectedSessionIdRef.current) { - window.ade.agentChat.slashCommands({ sessionId: envelope.sessionId }) + getAgentChatSlashCommandsCached( + { sessionId: envelope.sessionId }, + { + force: envelope.event.type === "system_notice", + }, + ) .then(setSdkSlashCommands) .catch(() => {}); } @@ -8313,7 +8319,7 @@ export function AgentChatPane({ cursorModeId: updatedSession.cursorModeId, cursorModeSnapshot: updatedSession.cursorModeSnapshot, }); - window.ade.agentChat.slashCommands({ sessionId: selectedSessionId }) + getAgentChatSlashCommandsCached({ sessionId: selectedSessionId }, { force: true }) .then(setSdkSlashCommands) .catch(() => {}); if ( diff --git a/apps/desktop/src/renderer/lib/agentChatSlashCommandsCache.test.ts b/apps/desktop/src/renderer/lib/agentChatSlashCommandsCache.test.ts new file mode 100644 index 000000000..236e36a93 --- /dev/null +++ b/apps/desktop/src/renderer/lib/agentChatSlashCommandsCache.test.ts @@ -0,0 +1,70 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentChatSlashCommand } from "../../shared/types"; +import { + getAgentChatSlashCommandsCached, + invalidateAgentChatSlashCommandsCache, +} from "./agentChatSlashCommandsCache"; + +function command(name: string): AgentChatSlashCommand { + return { + name, + description: `${name} command`, + source: "sdk", + }; +} + +describe("agentChatSlashCommandsCache", () => { + beforeEach(() => { + invalidateAgentChatSlashCommandsCache(); + globalThis.window.ade = { + agentChat: { + slashCommands: vi.fn(), + }, + } as any; + }); + + afterEach(() => { + invalidateAgentChatSlashCommandsCache(); + vi.restoreAllMocks(); + }); + + it("coalesces identical in-flight session requests", async () => { + let resolveCommands: (commands: AgentChatSlashCommand[]) => void = () => {}; + const pending = new Promise((resolve) => { + resolveCommands = resolve; + }); + const slashCommands = vi.mocked(window.ade.agentChat.slashCommands); + slashCommands.mockReturnValue(pending as any); + + const first = getAgentChatSlashCommandsCached({ sessionId: "session-1" }); + const second = getAgentChatSlashCommandsCached({ sessionId: "session-1" }); + + expect(slashCommands).toHaveBeenCalledTimes(1); + resolveCommands([command("/plan")]); + await expect(first).resolves.toEqual([command("/plan")]); + await expect(second).resolves.toEqual([command("/plan")]); + + await expect(getAgentChatSlashCommandsCached({ sessionId: "session-1" })).resolves.toEqual([command("/plan")]); + expect(slashCommands).toHaveBeenCalledTimes(1); + }); + + it("keeps lane/provider keys separate and supports forced refresh", async () => { + const slashCommands = vi.mocked(window.ade.agentChat.slashCommands); + slashCommands + .mockResolvedValueOnce([command("/codex")]) + .mockResolvedValueOnce([command("/claude")]) + .mockResolvedValueOnce([command("/codex-new")]); + + await expect(getAgentChatSlashCommandsCached({ laneId: "lane-1", provider: "codex" })).resolves.toEqual([command("/codex")]); + await expect(getAgentChatSlashCommandsCached({ laneId: "lane-1", provider: "claude" })).resolves.toEqual([command("/claude")]); + await expect(getAgentChatSlashCommandsCached({ laneId: "lane-1", provider: "codex" })).resolves.toEqual([command("/codex")]); + + await expect( + getAgentChatSlashCommandsCached({ laneId: "lane-1", provider: "codex" }, { force: true }), + ).resolves.toEqual([command("/codex-new")]); + + expect(slashCommands).toHaveBeenCalledTimes(3); + }); +}); diff --git a/apps/desktop/src/renderer/lib/agentChatSlashCommandsCache.ts b/apps/desktop/src/renderer/lib/agentChatSlashCommandsCache.ts new file mode 100644 index 000000000..4648aa657 --- /dev/null +++ b/apps/desktop/src/renderer/lib/agentChatSlashCommandsCache.ts @@ -0,0 +1,61 @@ +import type { + AgentChatSlashCommand, + AgentChatSlashCommandsArgs, +} from "../../shared/types"; + +type CacheEntry = { + value?: AgentChatSlashCommand[]; + promise?: Promise; + expiresAt: number; +}; + +const DEFAULT_TTL_MS = 5_000; +const slashCommandCache = new Map(); + +function cacheKey(args: AgentChatSlashCommandsArgs): string { + if (args.sessionId?.trim()) return `session:${args.sessionId.trim()}`; + const lane = args.laneId?.trim() || "__no_lane__"; + const provider = args.provider?.trim() || "__no_provider__"; + return `lane:${lane}:provider:${provider}`; +} + +export async function getAgentChatSlashCommandsCached( + args: AgentChatSlashCommandsArgs, + options?: { force?: boolean; ttlMs?: number }, +): Promise { + const key = cacheKey(args); + const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS; + const now = Date.now(); + const cached = slashCommandCache.get(key); + + if (cached?.promise) return cached.promise; + if (!options?.force && cached?.value !== undefined && cached.expiresAt > now) { + return cached.value; + } + + const promise = window.ade.agentChat.slashCommands(args).then((value) => { + slashCommandCache.set(key, { + value, + expiresAt: Date.now() + ttlMs, + }); + return value; + }).catch((error) => { + const current = slashCommandCache.get(key); + if (current?.promise === promise) slashCommandCache.delete(key); + throw error; + }); + + slashCommandCache.set(key, { + promise, + expiresAt: now + ttlMs, + }); + return promise; +} + +export function invalidateAgentChatSlashCommandsCache(args?: AgentChatSlashCommandsArgs): void { + if (!args) { + slashCommandCache.clear(); + return; + } + slashCommandCache.delete(cacheKey(args)); +} From 4806ae14d1f268fa7412a7ba55eb3b504a89aab8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:13:57 -0400 Subject: [PATCH 28/80] fix(work): cache chat session lists --- .../components/chat/AgentChatPane.test.tsx | 3 + .../components/chat/AgentChatPane.tsx | 45 +++++--- .../lib/agentChatSessionListCache.test.ts | 103 ++++++++++++++++++ .../renderer/lib/agentChatSessionListCache.ts | 97 +++++++++++++++++ 4 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/agentChatSessionListCache.test.ts create mode 100644 apps/desktop/src/renderer/lib/agentChatSessionListCache.ts diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index c67790726..52d1b97dc 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -16,6 +16,7 @@ import type { TerminalSessionDetail, } from "../../../shared/types"; import { createDynamicCursorCliModelDescriptor, getModelById } from "../../../shared/modelRegistry"; +import { invalidateAgentChatSessionListCache } from "../../lib/agentChatSessionListCache"; import { invalidateAgentChatSlashCommandsCache } from "../../lib/agentChatSlashCommandsCache"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { DRAFT_LAUNCH_JOB_STALE_AFTER_MS } from "../../lib/draftLaunchJobs"; @@ -685,6 +686,7 @@ function installMatchMediaMock(): void { beforeEach(() => { installMatchMediaMock(); + invalidateAgentChatSessionListCache(); invalidateAgentChatSlashCommandsCache(); invalidateAiDiscoveryCache(); invalidateProjectConfigCache(); @@ -715,6 +717,7 @@ beforeEach(() => { afterEach(() => { cleanup(); + invalidateAgentChatSessionListCache(); invalidateAgentChatSlashCommandsCache(); invalidateAiDiscoveryCache(); invalidateProjectConfigCache(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 9d99e3b3d..569f91a1c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -136,6 +136,10 @@ import { import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { WorkSurfaceHeader } from "../work/WorkSurfaceHeader"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; +import { + invalidateAgentChatSessionListCache, + listAgentChatSessionsCached, +} from "../../lib/agentChatSessionListCache"; import { getAgentChatSlashCommandsCached } from "../../lib/agentChatSlashCommandsCache"; import { getAgentChatModelsCached, getAiStatusCached, invalidateAiDiscoveryCache, peekAiStatusCached } from "../../lib/aiDiscoveryCache"; import { getProjectConfigCached } from "../../lib/projectConfigCache"; @@ -3921,6 +3925,10 @@ export function AgentChatPane({ }); }, []); + const invalidateCurrentChatSessionList = useCallback(() => { + invalidateAgentChatSessionListCache(laneId ? { laneId } : undefined); + }, [laneId]); + const refreshLockedSessionSummary = useCallback(async () => { if (!lockSessionId) { setSessions([]); @@ -3948,7 +3956,7 @@ export function AgentChatPane({ return summary; }, [initialSessionSummary, lockSessionId]); - const refreshSessions = useCallback(async () => { + const refreshSessions = useCallback(async (options?: { force?: boolean }) => { if (lockedSingleSessionMode && lockSessionId) { await refreshLockedSessionSummary(); return; @@ -3964,7 +3972,10 @@ export function AgentChatPane({ return; } - const allRows = await window.ade.agentChat.list({ laneId }); + const allRows = await listAgentChatSessionsCached( + { laneId }, + options?.force ? { force: true } : undefined, + ); const rows = allRows.filter((session) => !session.archivedAt); setArchivedSessions(sortSessionSummariesByRecency( allRows.filter((session) => Boolean(session.archivedAt)), @@ -5623,6 +5634,7 @@ export function AgentChatPane({ ...nativeControlPayload, ...orchestratorOverrides, }); + invalidateAgentChatSessionListCache({ laneId: targetLaneId }); // Follow-up: allocate the orchestration bundle. We do this immediately // so the bundle path is persisted alongside the new chat (workers will // pick it up from the manifest). If it fails, stop before sending the @@ -5687,11 +5699,11 @@ export function AgentChatPane({ sessionId: created.id, modelId: launchModelId, }).then(() => { - if (targetLaneId === laneId) void refreshSessions(); + if (targetLaneId === laneId) void refreshSessions({ force: true }); }).catch(() => { /* warmup is best-effort */ }); } if (options.notify) notifySessionCreated(created, options.notifyOptions); - if (targetLaneId === laneId) void refreshSessions().catch(() => {}); + if (targetLaneId === laneId) void refreshSessions({ force: true }).catch(() => {}); return created; }, [codexFastMode, constrainedModelSelectionError, currentNativeControls, executionMode, initialNativeControls, laneId, lastLaunchConfigStorageKey, modelId, notifySessionCreated, patchSessionSummary, reasoningEffort, refreshSessions, touchSession, workDraftKind]); @@ -5970,8 +5982,9 @@ export function AgentChatPane({ optimisticSessionIdsRef.current.delete(session.id); knownSessionIdsRef.current.delete(session.id); invalidateSessionListCache(); + invalidateAgentChatSessionListCache({ laneId: targetLane.laneId }); if (targetLane.laneId === laneId) { - await refreshSessions().catch(() => undefined); + await refreshSessions({ force: true }).catch(() => undefined); } }, [laneId, refreshSessions]); @@ -6165,8 +6178,9 @@ export function AgentChatPane({ ? await startDraftChatLaunch(prepared, targetLane) : await startDraftCliLaunch(prepared, targetLane, mode); invalidateSessionListCache(); + invalidateAgentChatSessionListCache({ laneId: targetLane.laneId }); if (launched.draftKind === "chat" && targetLane.laneId === laneId) { - void refreshSessions().catch(() => {}); + void refreshSessions({ force: true }).catch(() => {}); } const launch = { laneId: targetLane.laneId, @@ -6266,7 +6280,8 @@ export function AgentChatPane({ }); setChatActionsOpen(false); notifySessionCreated(result.session); - void refreshSessions().catch(() => {}); + invalidateCurrentChatSessionList(); + void refreshSessions({ force: true }).catch(() => {}); } catch (handoffError) { setError(handoffError instanceof Error ? handoffError.message : String(handoffError)); } finally { @@ -6288,6 +6303,7 @@ export function AgentChatPane({ handoffOpenCodePermissionMode, handoffReasoningEffort, handoffTargetProvider, + invalidateCurrentChatSessionList, notifySessionCreated, refreshSessions, selectedSession?.permissionMode, @@ -6307,10 +6323,11 @@ export function AgentChatPane({ void window.ade.agentChat.delete({ sessionId: selectedSessionId }) .then(async () => { invalidateSessionListCache(); + invalidateCurrentChatSessionList(); draftsPerSessionRef.current.delete(selectedSessionId); localTouchBySessionRef.current.delete(selectedSessionId); loadedHistoryRef.current.delete(selectedSessionId); - await refreshSessions().catch(() => {}); + await refreshSessions({ force: true }).catch(() => {}); }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); @@ -6319,23 +6336,24 @@ export function AgentChatPane({ .finally(() => { setDeletingChatSessionId((current) => (current === selectedSessionId ? null : current)); }); - }, [refreshSessions, selectedSession, selectedSessionId]); + }, [invalidateCurrentChatSessionList, refreshSessions, selectedSession, selectedSessionId]); const handleArchiveChat = useCallback((sessionId: string) => { setError(null); void window.ade.agentChat.archive({ sessionId }) .then(async () => { invalidateSessionListCache(); + invalidateCurrentChatSessionList(); if (selectedSessionIdRef.current === sessionId) { setSelectedSessionId(null); } - await refreshSessions().catch(() => {}); + await refreshSessions({ force: true }).catch(() => {}); }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); setError(`Archive failed: ${message}`); }); - }, [refreshSessions]); + }, [invalidateCurrentChatSessionList, refreshSessions]); const archiveConfirm = useConfirmDialog(); const requestArchiveChat = useCallback( @@ -6355,13 +6373,14 @@ export function AgentChatPane({ void window.ade.agentChat.unarchive({ sessionId }) .then(async () => { invalidateSessionListCache(); - await refreshSessions().catch(() => {}); + invalidateCurrentChatSessionList(); + await refreshSessions({ force: true }).catch(() => {}); }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); setError(`Restore failed: ${message}`); }); - }, [refreshSessions]); + }, [invalidateCurrentChatSessionList, refreshSessions]); // ── Eager session creation ── // Create a session as soon as we have a model + lane, so slash commands diff --git a/apps/desktop/src/renderer/lib/agentChatSessionListCache.test.ts b/apps/desktop/src/renderer/lib/agentChatSessionListCache.test.ts new file mode 100644 index 000000000..b427a6677 --- /dev/null +++ b/apps/desktop/src/renderer/lib/agentChatSessionListCache.test.ts @@ -0,0 +1,103 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentChatSessionSummary } from "../../shared/types"; +import { useAppStore } from "../state/appStore"; +import { + invalidateAgentChatSessionListCache, + listAgentChatSessionsCached, +} from "./agentChatSessionListCache"; + +function session(sessionId: string): AgentChatSessionSummary { + return { + sessionId, + laneId: "lane-1", + provider: "codex", + title: sessionId, + status: "idle", + createdAt: "2026-06-04T00:00:00.000Z", + updatedAt: "2026-06-04T00:00:00.000Z", + lastMessageAt: null, + archivedAt: null, + } as unknown as AgentChatSessionSummary; +} + +describe("agentChatSessionListCache", () => { + beforeEach(() => { + invalidateAgentChatSessionListCache(); + useAppStore.setState({ + project: { rootPath: "/tmp/project-a", name: "Project A" } as any, + projectBinding: { + kind: "local", + key: "local:/tmp/project-a", + rootPath: "/tmp/project-a", + displayName: "Project A", + }, + } as any); + globalThis.window.ade = { + agentChat: { + list: vi.fn(), + }, + } as any; + }); + + afterEach(() => { + invalidateAgentChatSessionListCache(); + vi.restoreAllMocks(); + }); + + it("coalesces identical in-flight lane requests", async () => { + let resolveRows: (rows: AgentChatSessionSummary[]) => void = () => {}; + const pending = new Promise((resolve) => { + resolveRows = resolve; + }); + const list = vi.mocked(window.ade.agentChat.list); + list.mockReturnValue(pending as any); + + const first = listAgentChatSessionsCached({ laneId: "lane-1" }); + const second = listAgentChatSessionsCached({ laneId: "lane-1" }); + + expect(list).toHaveBeenCalledTimes(1); + resolveRows([session("session-1")]); + await expect(first).resolves.toEqual([session("session-1")]); + await expect(second).resolves.toEqual([session("session-1")]); + + await expect(listAgentChatSessionsCached({ laneId: "lane-1" })).resolves.toEqual([session("session-1")]); + expect(list).toHaveBeenCalledTimes(1); + }); + + it("separates project roots and allows scoped invalidation", async () => { + const list = vi.mocked(window.ade.agentChat.list); + list + .mockResolvedValueOnce([session("project-a-session")]) + .mockResolvedValueOnce([session("project-b-session")]) + .mockResolvedValueOnce([session("project-a-new")]); + + await expect(listAgentChatSessionsCached({ laneId: "lane-1" })).resolves.toEqual([session("project-a-session")]); + + useAppStore.setState({ + project: { rootPath: "/tmp/project-b", name: "Project B" } as any, + projectBinding: { + kind: "local", + key: "local:/tmp/project-b", + rootPath: "/tmp/project-b", + displayName: "Project B", + }, + } as any); + await expect(listAgentChatSessionsCached({ laneId: "lane-1" })).resolves.toEqual([session("project-b-session")]); + + invalidateAgentChatSessionListCache({ projectRoot: "/tmp/project-a", laneId: "lane-1" }); + useAppStore.setState({ + project: { rootPath: "/tmp/project-a", name: "Project A" } as any, + projectBinding: { + kind: "local", + key: "local:/tmp/project-a", + rootPath: "/tmp/project-a", + displayName: "Project A", + }, + } as any); + + await expect(listAgentChatSessionsCached({ laneId: "lane-1" })).resolves.toEqual([session("project-a-new")]); + expect(list).toHaveBeenCalledTimes(3); + }); +}); diff --git a/apps/desktop/src/renderer/lib/agentChatSessionListCache.ts b/apps/desktop/src/renderer/lib/agentChatSessionListCache.ts new file mode 100644 index 000000000..f52555ed1 --- /dev/null +++ b/apps/desktop/src/renderer/lib/agentChatSessionListCache.ts @@ -0,0 +1,97 @@ +import type { + AgentChatListArgs, + AgentChatSessionSummary, +} from "../../shared/types"; +import { useAppStore } from "../state/appStore"; + +type CacheEntry = { + value?: AgentChatSessionSummary[]; + promise?: Promise; + expiresAt: number; +}; + +const DEFAULT_TTL_MS = 1_500; +const chatSessionListCache = new Map(); + +function normalizeArgs(args?: AgentChatListArgs): AgentChatListArgs { + const normalized: AgentChatListArgs = {}; + if (args?.laneId?.trim()) normalized.laneId = args.laneId.trim(); + if (typeof args?.includeAutomation === "boolean") normalized.includeAutomation = args.includeAutomation; + if (typeof args?.includeArchived === "boolean") normalized.includeArchived = args.includeArchived; + return normalized; +} + +function activeProjectRoot(): string | null { + const state = useAppStore.getState(); + if (state.projectBinding?.kind === "remote") return state.projectBinding.rootPath?.trim() || null; + return state.project?.rootPath?.trim() || null; +} + +function cacheKey(args?: AgentChatListArgs): string { + const normalized = normalizeArgs(args); + return JSON.stringify({ + projectRoot: activeProjectRoot(), + laneId: normalized.laneId ?? null, + includeAutomation: normalized.includeAutomation ?? null, + includeArchived: normalized.includeArchived ?? null, + }); +} + +export async function listAgentChatSessionsCached( + args?: AgentChatListArgs, + options?: { force?: boolean; ttlMs?: number }, +): Promise { + const key = cacheKey(args); + const now = Date.now(); + const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS; + const cached = chatSessionListCache.get(key); + + if (cached?.promise) return cached.promise; + if (!options?.force && cached?.value !== undefined && cached.expiresAt > now) { + return cached.value; + } + + const normalized = normalizeArgs(args); + const promise = window.ade.agentChat.list(normalized).then((value) => { + chatSessionListCache.set(key, { + value, + expiresAt: Date.now() + ttlMs, + }); + return value; + }).catch((error) => { + const current = chatSessionListCache.get(key); + if (current?.promise === promise) chatSessionListCache.delete(key); + throw error; + }); + + chatSessionListCache.set(key, { + promise, + expiresAt: now + ttlMs, + }); + return promise; +} + +export function invalidateAgentChatSessionListCache(scope?: { + projectRoot?: string | null; + laneId?: string | null; +}): void { + if (!scope) { + chatSessionListCache.clear(); + return; + } + + const projectRootFilter = scope.projectRoot === undefined ? undefined : scope.projectRoot?.trim() || null; + const laneIdFilter = scope.laneId === undefined ? undefined : scope.laneId?.trim() || null; + for (const key of [...chatSessionListCache.keys()]) { + let parsed: { projectRoot?: string | null; laneId?: string | null }; + try { + parsed = JSON.parse(key) as { projectRoot?: string | null; laneId?: string | null }; + } catch { + chatSessionListCache.delete(key); + continue; + } + if (projectRootFilter !== undefined && parsed.projectRoot !== projectRootFilter) continue; + if (laneIdFilter !== undefined && parsed.laneId !== laneIdFilter) continue; + chatSessionListCache.delete(key); + } +} From de43403f253d300bc17b3eb161918c9d579528b0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:17:44 -0400 Subject: [PATCH 29/80] fix(work): skip remote focus refetch --- .../terminals/useWorkSessions.test.ts | 29 +++++++++++++++++++ .../components/terminals/useWorkSessions.ts | 2 ++ 2 files changed, 31 insertions(+) diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index 624619cd0..e45ceae79 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -1557,6 +1557,35 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { expect(listSessionsCachedMock).toHaveBeenCalledWith({ limit: 500 }, undefined); }); + it("does not refetch remote Work on focus without hidden changes", async () => { + fakeAppStoreState.projectBinding = { + kind: "remote", + key: "remote:target:project", + targetId: "target", + runtimeName: "Mac Studio", + projectId: "project", + rootPath: "/Users/admin/Projects/perf pass", + displayName: "perf pass", + }; + + renderHook(() => useWorkSessions()); + + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + listSessionsCachedMock.mockClear(); + vi.mocked(invalidateSessionListCache).mockClear(); + + await act(async () => { + window.dispatchEvent(new Event("focus")); + await new Promise((r) => setTimeout(r, 140)); + }); + + expect(invalidateSessionListCache).not.toHaveBeenCalled(); + expect(listSessionsCachedMock).not.toHaveBeenCalled(); + }); + it("does not subscribe or refresh while the kept-alive Work surface is inactive", async () => { const windowAddEventListenerSpy = vi.spyOn(window, "addEventListener"); const documentAddEventListenerSpy = vi.spyOn(document, "addEventListener"); diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index 0dded4c8f..c5a1d0cc3 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -1126,6 +1126,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) if (document.visibilityState !== "visible") return; const hadHiddenChanges = pendingHiddenSessionRefreshRef.current; pendingHiddenSessionRefreshRef.current = false; + const remoteProject = useAppStore.getState().projectBinding?.kind === "remote"; + if (remoteProject && !hadHiddenChanges) return; invalidateSessionListCache(); scheduleBackgroundRefresh(hadHiddenChanges ? 20 : 120); }; From 4f15e985132cc502638beb39b7bd305e0b353356 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:22:16 -0400 Subject: [PATCH 30/80] fix(remote): defer shell github status --- .../components/app/AppShell.aiStatus.test.tsx | 71 ++++++++++++++++++- .../src/renderer/components/app/AppShell.tsx | 13 +++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx index 99f28192a..b05f1f03c 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx @@ -82,6 +82,7 @@ function resetStore() { describe("AppShell AI provider status", () => { const getStatusMock = vi.fn(); + const githubGetStatusMock = vi.fn(); let chatEventListener: ((envelope: AgentChatEventEnvelope) => void) | null = null; beforeEach(() => { @@ -90,6 +91,8 @@ describe("AppShell AI provider status", () => { resetStore(); invalidateAiDiscoveryCache(); getStatusMock.mockReset(); + githubGetStatusMock.mockReset(); + githubGetStatusMock.mockResolvedValue(null); chatEventListener = null; Object.defineProperty(window, "ade", { configurable: true, @@ -117,7 +120,7 @@ describe("AppShell AI provider status", () => { onUpdate: vi.fn(() => () => {}), }, github: { - getStatus: vi.fn(async () => null), + getStatus: githubGetStatusMock, onStatusChanged: vi.fn(() => () => {}), }, keybindings: { @@ -224,4 +227,70 @@ describe("AppShell AI provider status", () => { refreshOpenCodeInventory: false, }); }); + + it("skips shell GitHub auth discovery on remote Work startup", async () => { + getStatusMock.mockResolvedValue(makeAiStatus(true)); + useAppStore.setState({ + project, + projectBinding: { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + runtimeName: "Mac Studio", + displayName: "perf pass", + rootPath: "/Users/admin/Projects/perf pass", + }, + projectHydrated: true, + showWelcome: false, + } as any); + + render( + + +
Work content
+
+
, + ); + + await act(async () => { + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + }); + + expect(githubGetStatusMock).not.toHaveBeenCalled(); + }); + + it("loads shell GitHub auth discovery on remote PR routes", async () => { + getStatusMock.mockResolvedValue(makeAiStatus(true)); + useAppStore.setState({ + project, + projectBinding: { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + runtimeName: "Mac Studio", + displayName: "perf pass", + rootPath: "/Users/admin/Projects/perf pass", + }, + projectHydrated: true, + showWelcome: false, + } as any); + + render( + + +
PR content
+
+
, + ); + + await act(async () => { + vi.advanceTimersByTime(12_000); + await Promise.resolve(); + }); + + expect(githubGetStatusMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 93dc676bc..2c6161f2f 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -67,6 +67,14 @@ function primaryTabPath(pathname: string): string { return roots.find((root) => pathname === root || pathname.startsWith(`${root}/`)) ?? pathname; } +function shouldLoadShellGithubStatus(pathname: string, isRemoteProject: boolean): boolean { + if (!isRemoteProject) return true; + return pathname === "/prs" + || pathname.startsWith("/prs/") + || pathname === "/settings" + || pathname.startsWith("/settings/"); +} + const PROJECT_ROUTE_STORAGE_PREFIX = "ade:project-route:"; const AI_STATUS_STARTUP_DELAY_MS = 1_000; const AI_STATUS_CHAT_EVENT_REFRESH_MIN_GAP_MS = 30_000; @@ -297,6 +305,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const dismissGithubBanner = useAppStore((s) => s.dismissGithubBanner); const currentProjectRoot = projectBinding?.kind === "remote" ? projectBinding.rootPath : (project?.rootPath ?? null); + const isRemoteProject = projectBinding?.kind === "remote"; const missingAiBannerDismissed = Boolean( currentProjectRoot && dismissedMissingAiBannerRoots[currentProjectRoot], ); @@ -824,7 +833,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { useEffect(() => { let cancelled = false; - if (!currentProjectRoot) { + if (!currentProjectRoot || !shouldLoadShellGithubStatus(location.pathname, isRemoteProject)) { githubStatusProjectRootRef.current = null; setGithubStatus(null); return; @@ -849,7 +858,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { cancelled = true; window.clearTimeout(githubTimer); }; - }, [currentProjectRoot]); + }, [currentProjectRoot, isRemoteProject, location.pathname]); // Refresh the GitHub banner the moment Settings saves/clears a token, so the // shell does not lag behind the Settings UI (the original "banner stays up From ce8efdd3bb6867a20fd19f28a5443b5c0a1d74b9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:28:10 -0400 Subject: [PATCH 31/80] fix(work): lazy load remote chat git state --- .../components/chat/ChatGitToolbar.test.tsx | 58 ++++++++++++++++- .../components/chat/ChatGitToolbar.tsx | 64 ++++++++++++++----- 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx index 4f8ebf6ac..50c123c2b 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { useAppStore } from "../../state/appStore"; @@ -50,8 +50,26 @@ function installAdeMocks() { } as any; } -function resetStore() { +function resetStore(options?: { remote?: boolean }) { + const rootPath = options?.remote ? "/Users/admin/Projects/perf pass" : "/tmp/project"; useAppStore.setState({ + project: { rootPath, displayName: options?.remote ? "perf pass" : "Project" } as any, + projectBinding: options?.remote + ? { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + runtimeName: "Mac Studio", + displayName: "perf pass", + rootPath, + } as any + : { + kind: "local", + key: `local:${rootPath}`, + rootPath, + displayName: "Project", + } as any, lanes: [{ id: "lane-1", name: "UI audit lane", @@ -111,4 +129,40 @@ describe("ChatGitToolbar", () => { "/prs?tab=normal&create=1&sourceLaneId=lane-1&target=primary", ); }); + + it("does not load diff or PR state when mounted for a remote project", async () => { + resetStore({ remote: true }); + + renderToolbar(); + + await Promise.resolve(); + + expect(window.ade.diff.getChanges).not.toHaveBeenCalled(); + expect(window.ade.prs.getForLane).not.toHaveBeenCalled(); + }); + + it("resolves the linked PR on first remote PR click before routing", async () => { + resetStore({ remote: true }); + vi.mocked(window.ade.prs.getForLane).mockResolvedValue({ + id: "pr-1", + laneId: "lane-1", + title: "Remote linked PR", + state: "open", + checksStatus: "unknown", + githubUrl: "https://github.com/acme/perf-pass/pull/1", + additions: 0, + deletions: 0, + updatedAt: null, + } as any); + + renderToolbar(); + + fireEvent.click(await screen.findByRole("button", { name: "PR" })); + + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toBe("/prs?tab=normal&prId=pr-1"); + }); + expect(window.ade.prs.getForLane).toHaveBeenCalledTimes(1); + expect(window.ade.diff.getChanges).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx index 91305077f..e9c98f2e0 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx @@ -16,6 +16,7 @@ import { cn } from "../ui/cn"; import type { DiffChanges, PrSummary, PrCheck } from "../../../shared/types"; import { useLaneGitActionRuntimeState } from "../lanes/LaneGitActionsPane"; import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; +import { useAppStore } from "../../state/appStore"; // --------------------------------------------------------------------------- // Types @@ -103,9 +104,12 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ }: ChatGitToolbarProps) { const navigate = useNavigate(); const runtime = useLaneGitActionRuntimeState(laneId); + const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const [dirtyCount, setDirtyCount] = useState(0); const [linkedPr, setLinkedPr] = useState(null); + const [prLoaded, setPrLoaded] = useState(false); + const [prActionBusy, setPrActionBusy] = useState(false); const [prMenuOpen, setPrMenuOpen] = useState(false); const [prChecks, setPrChecks] = useState(null); const [prChecksLoading, setPrChecksLoading] = useState(false); @@ -128,25 +132,39 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ try { const pr = await window.ade.prs.getForLane(laneId); setLinkedPr(pr); + setPrLoaded(true); + return pr; } catch { setLinkedPr(null); + setPrLoaded(true); + return null; } }, [laneId]); useEffect(() => { + setDirtyCount(0); + setLinkedPr(null); + setPrLoaded(false); + setPrMenuOpen(false); + setPrChecks(null); + if (isRemoteProject) return; void refreshStatus(); void refreshPr(); - }, [refreshStatus, refreshPr]); + }, [isRemoteProject, refreshStatus, refreshPr]); // Re-poll after the runtime finishes an action (from either pane or toolbar) const prevBusy = React.useRef(runtime.busyAction); useEffect(() => { if (prevBusy.current && !runtime.busyAction) { + if (isRemoteProject && !prLoaded) { + prevBusy.current = runtime.busyAction; + return; + } void refreshStatus(); void refreshPr(); } prevBusy.current = runtime.busyAction; - }, [runtime.busyAction, refreshStatus, refreshPr]); + }, [isRemoteProject, prLoaded, runtime.busyAction, refreshStatus, refreshPr]); // Subscribe to backend PR events so the linked-PR pill reflects external // changes (PR closed, merged, checks finished, etc.) without a manual refresh. @@ -155,6 +173,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ if (event.type !== "prs-updated") return; // Only re-fetch when an update could plausibly touch this lane's PR. if (event.prs.some((pr) => pr.laneId === laneId)) { + if (isRemoteProject && !prLoaded && !linkedPr) return; void refreshPr(); } else if (linkedPr && !event.prs.some((pr) => pr.id === linkedPr.id)) { // The linked PR vanished from the latest snapshot — clear the pill. @@ -162,21 +181,36 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ } }); return unsubscribe; - }, [laneId, linkedPr, refreshPr]); + }, [isRemoteProject, laneId, linkedPr, prLoaded, refreshPr]); - const handlePr = useCallback(() => { + const handlePr = useCallback(async () => { if (linkedPr) { navigate(`/prs?tab=normal&prId=${encodeURIComponent(linkedPr.id)}`); - } else { - const params = new URLSearchParams({ - tab: "normal", - create: "1", - sourceLaneId: laneId, - target: "primary", - }); - navigate(`/prs?${params.toString()}`); + return; } - }, [laneId, linkedPr, navigate]); + + if (!prLoaded) { + setPrActionBusy(true); + const latestPr = await refreshPr().finally(() => setPrActionBusy(false)); + if (latestPr) { + navigate(`/prs?tab=normal&prId=${encodeURIComponent(latestPr.id)}`); + return; + } + } + + const params = new URLSearchParams({ + tab: "normal", + create: "1", + sourceLaneId: laneId, + target: "primary", + }); + navigate(`/prs?${params.toString()}`); + }, [laneId, linkedPr, navigate, prLoaded, refreshPr]); + + const handlePrClick = useCallback(() => { + if (prActionBusy) return; + void handlePr(); + }, [handlePr, prActionBusy]); // Reset menu state when the linked PR identity changes (lane switch, PR // unlinked) so stale data from another PR doesn't show. @@ -240,7 +274,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ } }, [linkedPr]); - const isBusy = Boolean(runtime.busyAction); + const isBusy = Boolean(runtime.busyAction) || prActionBusy; // ----------------------------------------------------------------------- // PR badge @@ -393,7 +427,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ ) : ( - From 948d358aec69b8886e1afa5c250e37db9f8c3149 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:32:39 -0400 Subject: [PATCH 32/80] fix(work): defer remote chat delta fetch --- .../components/chat/AgentChatPane.test.tsx | 73 +++++++++++++++++++ .../components/chat/AgentChatPane.tsx | 20 ++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 52d1b97dc..853af59a8 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -642,6 +642,7 @@ function installAdeMocks(options?: { function resetChatTestStore() { useAppStore.setState({ project: null, + projectBinding: null, laneSnapshots: [], lanes: [], selectedLaneId: null, @@ -796,6 +797,30 @@ function seedDrawerStore() { }); } +function seedRemoteChatStore() { + const rootPath = "/Users/admin/Projects/perf pass"; + useAppStore.setState({ + project: { rootPath, displayName: "perf pass" } as any, + projectBinding: { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + runtimeName: "Mac Studio", + displayName: "perf pass", + rootPath, + } as any, + lanes: [{ + id: "lane-1", + name: "remote lane", + branchRef: "refs/heads/remote-lane", + laneType: "worktree", + worktreePath: `${rootPath}/.ade/worktrees/remote-lane`, + } as any], + selectedLaneId: "lane-1", + }); +} + function renderDrawerPane() { const session = buildSession("session-1", { title: "Drawer audit chat" }); installAdeMocks({ sessions: [session] }); @@ -968,6 +993,54 @@ function sessionTabTitles(expectedTitles: string[]) { return tabs.map((button) => button.textContent?.trim()); } +describe("AgentChatPane remote session delta", () => { + it("skips mount-time session delta fetches for remote chats", async () => { + const session = buildSession("session-1", { status: "idle" }); + installAdeMocks({ sessions: [session] }); + seedRemoteChatStore(); + + renderPane(session); + + await screen.findByRole("button", { name: /^Select model/ }); + await Promise.resolve(); + + expect(window.ade.sessions.getDelta).not.toHaveBeenCalled(); + }); + + it("fetches remote session delta after a turn completes", async () => { + const session = buildSession("session-1", { status: "idle" }); + const mocks = installAdeMocks({ sessions: [session] }); + vi.mocked(window.ade.sessions.getDelta).mockResolvedValue({ insertions: 4, deletions: 2 } as any); + seedRemoteChatStore(); + + renderPane(session); + + await screen.findByRole("button", { name: /^Select model/ }); + expect(window.ade.sessions.getDelta).not.toHaveBeenCalled(); + + await act(async () => { + mocks.emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-06-04T12:00:00.000Z", + event: { type: "status", turnStatus: "started", turnId: "turn-1" }, + } as any); + }); + expect(window.ade.sessions.getDelta).not.toHaveBeenCalled(); + + await act(async () => { + mocks.emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-06-04T12:00:01.000Z", + event: { type: "status", turnStatus: "completed", turnId: "turn-1" }, + } as any); + }); + + await waitFor(() => { + expect(window.ade.sessions.getDelta).toHaveBeenCalledWith(session.sessionId); + }); + }); +}); + describe("AgentChatPane companion drawers", () => { it("opens and closes the iOS simulator and App Control drawers from chat chrome", async () => { renderDrawerPane(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 569f91a1c..2b1df0d0d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -4721,9 +4721,25 @@ export function AgentChatPane({ return () => { cancelled = true; }; }, [isTileActive, laneId, selectedSessionId, sessionProvider]); - // Fetch git diff stats when the session changes or a turn completes + const sessionDeltaTurnActiveRef = useRef(false); + const sessionDeltaSessionIdRef = useRef(null); + + // Fetch git diff stats when the session changes or a turn completes. Remote + // chats skip the mount-time decoration fetch; the bridge should stay focused + // on loading the transcript until the user actually runs a turn. useEffect(() => { if (!selectedSessionId || !isTileActive) { setSessionDelta(null); return; } + const sameSession = sessionDeltaSessionIdRef.current === selectedSessionId; + const previousTurnActive = sameSession ? sessionDeltaTurnActiveRef.current : false; + sessionDeltaSessionIdRef.current = selectedSessionId; + sessionDeltaTurnActiveRef.current = turnActive; + if (isRemoteProject) { + const completedTurn = sameSession && previousTurnActive && !turnActive; + if (!completedTurn) { + if (!turnActive) setSessionDelta(null); + return; + } + } let cancelled = false; const fetchDelta = () => { window.ade.sessions.getDelta(selectedSessionId) @@ -4739,7 +4755,7 @@ export function AgentChatPane({ }; fetchDelta(); return () => { cancelled = true; }; - }, [isTileActive, selectedSessionId, turnActive]); + }, [isRemoteProject, isTileActive, selectedSessionId, turnActive]); const flushQueuedEvents = useCallback(() => { const queued = pendingEventQueueRef.current; From 7eb6b7fb3e330798ad30fd367a1f155be0ec3935 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:41:42 -0400 Subject: [PATCH 33/80] fix(remote): prevent stale chat ai rechecks --- .../src/renderer/components/app/App.tsx | 6 ++- .../components/app/AppShell.aiStatus.test.tsx | 54 +++++++++++++++++++ .../src/renderer/components/app/AppShell.tsx | 9 +++- .../components/chat/AgentChatPane.test.tsx | 35 +++++++++++- .../components/chat/AgentChatPane.tsx | 6 ++- 5 files changed, 105 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 13776fe53..e3a016259 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -978,7 +978,11 @@ function BrowserHashRouteBridge() { export function App() { const theme = useAppStore((s) => s.theme); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore((s) => ( + s.projectBinding?.kind === "remote" + ? s.projectBinding.rootPath + : (s.project?.rootPath ?? null) + )); React.useEffect(() => { const w = window as Window & { __ADE_GET_DIRTY_FILE_TEXT__?: (p: string) => string | undefined }; diff --git a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx index b05f1f03c..20479a831 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx @@ -127,6 +127,7 @@ describe("AppShell AI provider status", () => { get: vi.fn(async () => null), }, lanes: { + list: vi.fn(async () => []), listSnapshots: vi.fn(async () => []), }, onboarding: { @@ -293,4 +294,57 @@ describe("AppShell AI provider status", () => { expect(githubGetStatusMock).toHaveBeenCalledTimes(1); }); + + it("ignores stale auth-related chat history while remote AI status is cached", async () => { + vi.setSystemTime(new Date("2026-06-04T12:00:00.000Z")); + getStatusMock.mockResolvedValue(makeAiStatus(true)); + const remoteRoot = "/Users/admin/Projects/perf pass"; + const remoteProject = { rootPath: remoteRoot, displayName: "perf pass", baseRef: "main" } as any; + const remoteBinding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + runtimeName: "Mac Studio", + displayName: "perf pass", + rootPath: remoteRoot, + } as any; + await getAiStatusCached({ projectRoot: remoteRoot }); + getStatusMock.mockClear(); + (window.ade.app.getWindowSession as any).mockResolvedValue({ + project: remoteProject, + binding: remoteBinding, + }); + useAppStore.setState({ + project: remoteProject, + projectBinding: remoteBinding, + projectHydrated: true, + showWelcome: false, + } as any); + + render( + + +
Work content
+
+
, + ); + + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + chatEventListener?.({ + sessionId: "session-1", + timestamp: "2026-06-04T11:59:00.000Z", + event: { + type: "error", + message: "not authenticated", + }, + }); + await Promise.resolve(); + }); + + expect(getStatusMock).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 2c6161f2f..5020ee771 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -778,6 +778,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { let refreshSerial = 0; let lastChatEventRefreshAt = 0; let lastKnownHasProvider = hasConfiguredAiProvider(cachedStatus); + const chatEventSubscriptionStartedAt = Date.now(); const refreshAiStatus = (options: { force?: boolean } = {}) => { if (document.visibilityState !== "visible") return; const serial = ++refreshSerial; @@ -809,6 +810,12 @@ export function AppShell({ children }: { children: React.ReactNode }) { window.addEventListener("focus", onFocus); document.addEventListener("visibilitychange", onVisibilityChange); const unsubscribeChatEvents = window.ade.agentChat.onEvent((envelope) => { + if (isRemoteProject) { + const eventTimestamp = Date.parse(envelope.timestamp); + if (Number.isFinite(eventTimestamp) && eventTimestamp < chatEventSubscriptionStartedAt - 10_000) { + return; + } + } if (lastKnownHasProvider && !shouldRefreshAiStatusForChatEvent(envelope)) return; const now = Date.now(); if (now - lastChatEventRefreshAt < AI_STATUS_CHAT_EVENT_REFRESH_MIN_GAP_MS) return; @@ -829,7 +836,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { window.removeEventListener(AI_STATUS_CACHE_INVALIDATED_EVENT, onAiStatusCacheInvalidated); unsubscribeChatEvents(); }; - }, [currentProjectRoot]); + }, [currentProjectRoot, isRemoteProject]); useEffect(() => { let cancelled = false; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 853af59a8..0f73cb893 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -18,7 +18,7 @@ import type { import { createDynamicCursorCliModelDescriptor, getModelById } from "../../../shared/modelRegistry"; import { invalidateAgentChatSessionListCache } from "../../lib/agentChatSessionListCache"; import { invalidateAgentChatSlashCommandsCache } from "../../lib/agentChatSlashCommandsCache"; -import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; +import { getAiStatusCached, invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { DRAFT_LAUNCH_JOB_STALE_AFTER_MS } from "../../lib/draftLaunchJobs"; import { invalidateProjectConfigCache } from "../../lib/projectConfigCache"; import { useAppStore } from "../../state/appStore"; @@ -819,6 +819,7 @@ function seedRemoteChatStore() { } as any], selectedLaneId: "lane-1", }); + return rootPath; } function renderDrawerPane() { @@ -993,7 +994,37 @@ function sessionTabTitles(expectedTitles: string[]) { return tabs.map((button) => button.textContent?.trim()); } -describe("AgentChatPane remote session delta", () => { +describe("AgentChatPane remote startup", () => { + it("uses the remote binding root for AI status cache lookups", async () => { + const session = buildSession("session-1", { status: "idle" }); + installAdeMocks({ sessions: [session] }); + window.ade.ai.getStatus = vi.fn().mockResolvedValue({ + mode: "subscription", + availableProviders: { + claude: { + binary: { present: false, source: "missing", path: null }, + auth: { ready: false, mode: "none", detail: null }, + }, + codex: true, + cursor: false, + droid: false, + }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + features: [], + availableModelIds: ["openai/gpt-5.4"], + }) as any; + const remoteRoot = seedRemoteChatStore(); + await getAiStatusCached({ projectRoot: remoteRoot }); + vi.mocked(window.ade.ai.getStatus).mockClear(); + + renderPane(session); + + await screen.findByRole("button", { name: /^Select model/ }); + await Promise.resolve(); + + expect(window.ade.ai.getStatus).not.toHaveBeenCalled(); + }); + it("skips mount-time session delta fetches for remote chats", async () => { const session = buildSession("session-1", { status: "idle" }); installAdeMocks({ sessions: [session] }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 2b1df0d0d..7e8da2b09 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -2342,7 +2342,11 @@ export function AgentChatPane({ /** Callback when lane selection changes in empty state */ onLaneChange?: (laneId: string) => void; }) { - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore((s) => ( + s.projectBinding?.kind === "remote" + ? s.projectBinding.rootPath + : (s.project?.rootPath ?? null) + )); const projectTransition = useAppStore((s) => s.projectTransition); const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); From c47012989315729d0dbbfc1601f25333e3208272 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:53:58 -0400 Subject: [PATCH 34/80] fix(remote): scope renderer state to active project root --- .../src/renderer/components/app/App.tsx | 9 ++---- .../src/renderer/components/app/AppShell.tsx | 4 +-- .../components/chat/AgentChatPane.tsx | 8 ++--- .../chat/ChatBuiltInBrowserPanel.tsx | 4 +-- .../components/graph/WorkspaceGraphPage.tsx | 6 ++-- .../lanes/LaneGitActionsPane.test.tsx | 7 +++++ .../components/lanes/LaneGitActionsPane.tsx | 4 +-- .../renderer/components/lanes/laneAgents.ts | 4 +-- .../lanes/useLaneWorkSessions.test.ts | 6 ++++ .../components/lanes/useLaneWorkSessions.ts | 4 +-- .../src/renderer/components/prs/PRsPage.tsx | 4 +-- .../components/prs/state/PrsContext.tsx | 4 +-- .../components/prs/tabs/GitHubTab.tsx | 6 ++-- .../components/prs/tabs/WorkflowsTab.tsx | 4 +-- .../src/renderer/components/run/RunPage.tsx | 4 +-- .../components/settings/LinearSection.tsx | 4 +-- .../terminals/TerminalView.test.tsx | 7 +++++ .../components/terminals/TerminalView.tsx | 3 +- .../terminals/TerminalsPage.test.tsx | 7 +++++ .../components/terminals/TerminalsPage.tsx | 4 +-- .../components/terminals/WorkSidebar.tsx | 4 +-- .../terminals/useWorkSessions.test.ts | 8 ++++- .../components/terminals/useWorkSessions.ts | 3 +- .../components/ui/PaneTilingLayout.tsx | 5 ++- .../renderer/lib/agentChatSessionListCache.ts | 6 ++-- .../src/renderer/lib/sessionListCache.ts | 4 +-- apps/desktop/src/renderer/state/appStore.ts | 31 ++++++++++++------- 27 files changed, 100 insertions(+), 64 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index e3a016259..ad0ac5462 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -95,6 +95,7 @@ import { AppStoreProvider, createProjectAppStore, hydrateProjectAppStore, + selectActiveProjectRoot, useAppStore, type AppStoreApi, } from "../../state/appStore"; @@ -359,7 +360,7 @@ function ProjectTransitionVeil({ label }: { label: string }) { function ProjectRouteContent({ active, route }: { active: boolean; route: string }) { const navigate = useNavigate(); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const setWorkViewState = useAppStore((s) => s.setWorkViewState); const workSurfaceRef = React.useRef(null); const lanesSurfaceRef = React.useRef(null); @@ -978,11 +979,7 @@ function BrowserHashRouteBridge() { export function App() { const theme = useAppStore((s) => s.theme); - const projectRoot = useAppStore((s) => ( - s.projectBinding?.kind === "remote" - ? s.projectBinding.rootPath - : (s.project?.rootPath ?? null) - )); + const projectRoot = useAppStore(selectActiveProjectRoot); React.useEffect(() => { const w = window as Window & { __ADE_GET_DIRTY_FILE_TEXT__?: (p: string) => string | undefined }; diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 5020ee771..2e3472ac0 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -752,7 +752,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { }, [project?.rootPath]); useEffect(() => { - const projectRoot = project?.rootPath ?? null; + const projectRoot = currentProjectRoot; if (!projectRoot || showWelcome) return; if (lastRouteSaveProjectRootRef.current !== projectRoot) { @@ -762,7 +762,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const route = serializeLocationRoute(location); if (route) writeStoredProjectRoute(projectRoot, route); - }, [location, project?.rootPath, showWelcome]); + }, [location, currentProjectRoot, showWelcome]); useEffect(() => { let cancelled = false; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 7e8da2b09..4415f2612 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -121,7 +121,7 @@ import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { ChatActionsDrawerPanel, type ChatActionsTab } from "./ChatActionsDrawerPanel"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { buildChatAppearanceRootStyle } from "./chatAppearance"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; import { LaneAccentDot } from "../lanes/LaneAccentDot"; @@ -2342,11 +2342,7 @@ export function AgentChatPane({ /** Callback when lane selection changes in empty state */ onLaneChange?: (laneId: string) => void; }) { - const projectRoot = useAppStore((s) => ( - s.projectBinding?.kind === "remote" - ? s.projectBinding.rootPath - : (s.project?.rootPath ?? null) - )); + const projectRoot = useAppStore(selectActiveProjectRoot); const projectTransition = useAppStore((s) => s.projectTransition); const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); diff --git a/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx index 2e6be5728..ed0fb4fb5 100644 --- a/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx @@ -19,7 +19,7 @@ import type { AgentChatFileRef } from "../../../shared/types"; import { inferAttachmentType } from "../../../shared/types"; import type { BuiltInBrowserTab } from "../../../shared/types/builtInBrowser"; import { consumePendingBuiltInBrowserNavigation } from "../../lib/openExternal"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { ADE_BROWSER_VIEW_OCCLUSION_END_EVENT, ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, @@ -623,7 +623,7 @@ export function ChatBuiltInBrowserPanel({ onAddAttachment, onInsertDraft, }: ChatBuiltInBrowserPanelProps) { - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const browserSurfaceRef = useRef(null); const browserWebviewsRef = useRef>(new Map()); const browserWebviewAttachCleanupRef = useRef void>>(new Map()); diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index 98da76118..84155ab90 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -48,7 +48,7 @@ import type { PrWithConflicts, IntegrationProposal } from "../../../shared/types"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { buildIntegrationSourcesByLaneId, isIntegrationLaneFromMetadata, @@ -163,7 +163,7 @@ function GraphInner({ active = true }: { active?: boolean }) { const [searchParams, setSearchParams] = useSearchParams(); const reactFlow = useReactFlow, Edge>(); const project = useAppStore((s) => s.project); - const projectRoot = project?.rootPath ?? null; + const projectRoot = useAppStore(selectActiveProjectRoot); const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const lanes = useAppStore((s) => s.lanes); const lanesKey = React.useMemo(() => lanes.map((l) => l.id).join(","), [lanes]); @@ -1202,7 +1202,7 @@ function GraphInner({ active = true }: { active?: boolean }) { reportGraphIssue("Conflict prediction live updates are unavailable in the graph.", error); } try { - const currentProjectRoot = project?.rootPath ?? null; + const currentProjectRoot = projectRoot; const isCurrentProjectEvent = (event: { projectRoot?: string | null }) => !event.projectRoot || event.projectRoot === currentProjectRoot; unsubPtyData = window.ade.pty.onData((event) => { diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx index e5c4a08c9..ca5272698 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx @@ -36,6 +36,13 @@ let mockAutoRebaseStatuses: Array<{ }> = []; vi.mock("../../state/appStore", () => ({ + selectActiveProjectRoot: (state: { + projectBinding?: { kind?: string; rootPath?: string | null } | null; + project?: { rootPath?: string | null } | null; + }) => { + if (state.projectBinding?.kind === "remote") return state.projectBinding.rootPath?.trim() || null; + return state.project?.rootPath?.trim() || null; + }, useAppStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), })); diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 38cf9756d..56c6d58ca 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowDown, ArrowLeft, ArrowsClockwise, CaretDown, CaretRight, Check, Folder, Stack, Trash, Upload, Warning } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { modifierKeyLabel } from "../../lib/platform"; import { cn } from "../ui/cn"; @@ -601,7 +601,7 @@ export function LaneGitActionsPane({ const lanes = useAppStore((s) => s.lanes); const refreshLanes = useAppStore((s) => s.refreshLanes); const selectLane = useAppStore((s) => s.selectLane); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const lane = useMemo(() => lanes.find((entry) => entry.id === laneId) ?? null, [lanes, laneId]); const parentLane = useMemo(() => { diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.ts index 45e84677a..54a34cdfb 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.ts @@ -5,7 +5,7 @@ import type { TerminalToolType, } from "../../../shared/types"; import { listSessionsCached } from "../../lib/sessionListCache"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; /** Unified live state for an agent row, glanceable at a list level. */ export type LaneAgentActivity = "working" | "awaiting-input" | "idle" | "ended"; @@ -134,7 +134,7 @@ export function buildLaneAgents( */ export function useLaneAgents(laneIds: string[]): Map { const [byLane, setByLane] = useState>(new Map()); - const projectRoot = useAppStore((state) => state.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const laneKey = useMemo(() => [...laneIds].sort().join(","), [laneIds]); const refreshTimerRef = useRef(null); const refreshInFlightRef = useRef(false); diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts index 10d889cfa..dd40f41ae 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts @@ -58,6 +58,12 @@ vi.mock("../../lib/sessions", () => ({ })); vi.mock("../../state/appStore", () => ({ + selectActiveProjectRoot: (state: Record) => { + const binding = state.projectBinding as { kind?: string; rootPath?: string | null } | null | undefined; + if (binding?.kind === "remote") return binding.rootPath?.trim() || null; + const project = state.project as { rootPath?: string | null } | null | undefined; + return project?.rootPath?.trim() || null; + }, useAppStore: vi.fn((selector: (state: Record) => unknown) => { const fakeState: Record = { project: { rootPath: fakeProjectRoot }, diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index efe832b17..d98273c4a 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; -import { useAppStore, type WorkDraftKind, type WorkProjectViewState, type WorkViewMode } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore, type WorkDraftKind, type WorkProjectViewState, type WorkViewMode } from "../../state/appStore"; import { listSessionsCached, invalidateSessionListCache } from "../../lib/sessionListCache"; import { sessionStatusBucket } from "../../lib/terminalAttention"; import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; @@ -113,7 +113,7 @@ function isActiveSession(session: TerminalSessionSummary): boolean { } export function useLaneWorkSessions(laneId: string | null) { - const projectRoot = useAppStore((state) => state.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const lanes = useAppStore((state) => state.lanes); const focusSession = useAppStore((state) => state.focusSession); const focusedSessionId = useAppStore((state) => state.focusedSessionId); diff --git a/apps/desktop/src/renderer/components/prs/PRsPage.tsx b/apps/desktop/src/renderer/components/prs/PRsPage.tsx index 686f408e9..717b332a1 100644 --- a/apps/desktop/src/renderer/components/prs/PRsPage.tsx +++ b/apps/desktop/src/renderer/components/prs/PRsPage.tsx @@ -5,7 +5,7 @@ import { EmptyState } from "../ui/EmptyState"; import { cn } from "../ui/cn"; import { PrsProvider, usePrs } from "./state/PrsContext"; import { CreatePrModal, type CreatePrModalInitialValues } from "./CreatePrModal"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { useDialogBus } from "../../lib/useDialogBus"; import { GitHubTab, type GitHubHeaderChromeState } from "./tabs/GitHubTab"; import { WorkflowsTab, type WorkflowCategory } from "./tabs/WorkflowsTab"; @@ -106,7 +106,7 @@ function createInitialValuesFromDialogProps(props?: Record): Cr function PRsPageInner() { const navigate = useNavigate(); const location = useLocation(); - const projectRoot = useAppStore((state) => state.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const refreshLanes = useAppStore((state) => state.refreshLanes); const { activeTab, diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index 57f2b998e..b74f054c7 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -36,7 +36,7 @@ import { buildPrAiResolutionContextKey } from "../../../../shared/types"; import { getModelById, resolveProviderGroupForModel, type ModelProviderGroup } from "../../../../shared/modelRegistry"; import { parsePrsRouteState, resolvePrsActiveTab } from "../prsRouteState"; import { resolveRouteRebaseSelection } from "../shared/rebaseNeedUtils"; -import { useAppStore } from "../../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../../state/appStore"; import { refreshPrsCoalesced } from "../../../lib/prReadCache"; type PrTab = "normal" | "queue" | "integration" | "rebase"; @@ -429,7 +429,7 @@ function diffPrIds(prev: PrWithConflicts[], next: PrWithConflicts[]): string[] { } export function PrsProvider({ active = true, children }: { active?: boolean; children: React.ReactNode }) { - const projectRoot = useAppStore((state) => state.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const cacheKey = prsContextCacheKey(projectRoot); const warmCache = useMemo(() => readPrsContextWarmCache(projectRoot), [projectRoot]); const warmCacheHydratedAtRef = React.useRef(warmCache?.dataLoadedAt ?? warmCache?.cachedAt ?? 0); diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx index 22f90fe0e..42840f757 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx @@ -19,7 +19,7 @@ import { EmptyState } from "../../ui/EmptyState"; import { ResizeGutter } from "../../ui/ResizeGutter"; import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, cardStyle, inlineBadge, outlineButton, primaryButton } from "../../lanes/laneDesignTokens"; import { LaneAccentDot } from "../../lanes/LaneAccentDot"; -import { useAppStore, useAppStoreApi } from "../../../state/appStore"; +import { selectActiveProjectRoot, useAppStore, useAppStoreApi } from "../../../state/appStore"; import { PrDetailPane } from "../detail/PrDetailPane"; import { formatTimestampShort, formatTimeAgoCompact } from "../shared/prFormatters"; import { PrCiRunningIndicator } from "../shared/prVisuals"; @@ -789,7 +789,7 @@ export function GitHubTab({ loading: prsContextLoading, setViewerLogin: setContextViewerLogin, } = usePrs(); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const refreshLanes = useAppStore((s) => s.refreshLanes); const selectLane = useAppStore((s) => s.selectLane); @@ -1342,7 +1342,7 @@ export function GitHubTab({ ? refreshLanes({ includeStatus: false, includeSnapshots: false }) .catch(() => {}) .then(() => { - const currentProjectRoot = appStore.getState().project?.rootPath ?? null; + const currentProjectRoot = selectActiveProjectRoot(appStore.getState()); if (createProjectRoot && currentProjectRoot !== createProjectRoot) return; appStore.setState((prev) => ({ lanes: upsertLaneSummary(prev.lanes, result.lane), diff --git a/apps/desktop/src/renderer/components/prs/tabs/WorkflowsTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/WorkflowsTab.tsx index 472d30554..0a2af64b1 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/WorkflowsTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/WorkflowsTab.tsx @@ -27,7 +27,7 @@ import { rebaseNeedItemKey } from "../shared/rebaseNeedUtils"; import { filterRebaseAttentionStatuses } from "../shared/rebaseAttentionUtils"; import { usePrs } from "../state/PrsContext"; import { getQueueWorkflowBucket } from "./queueWorkflowModel"; -import { useAppStore } from "../../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../../state/appStore"; const CATEGORY_THEMES = { integration: { color: "#8B5CF6", bg: "rgba(139, 92, 246, 0.08)", border: "rgba(139, 92, 246, 0.20)", bgSubtle: "rgba(139, 92, 246, 0.04)" }, @@ -673,7 +673,7 @@ export function WorkflowsTab({ } = usePrs(); const navigate = useNavigate(); - const projectRoot = useAppStore((state) => state.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const cacheKey = workflowsCacheKey(projectRoot); const warmCache = !WORKFLOWS_CACHE_DISABLED ? workflowsWarmCacheByProject.get(cacheKey) ?? null : null; const [view, setViewRaw] = React.useState(() => warmCache?.view ?? readWorkflowView(projectRoot)); diff --git a/apps/desktop/src/renderer/components/run/RunPage.tsx b/apps/desktop/src/renderer/components/run/RunPage.tsx index a3d55ff40..3984a40fb 100644 --- a/apps/desktop/src/renderer/components/run/RunPage.tsx +++ b/apps/desktop/src/renderer/components/run/RunPage.tsx @@ -9,7 +9,7 @@ import { Terminal, X, } from "@phosphor-icons/react"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { COLORS, LABEL_STYLE, @@ -690,7 +690,7 @@ export function RunPage() { const lanes = useAppStore((s) => s.lanes); const showWelcome = useAppStore((s) => s.showWelcome); - const projectRoot = project?.rootPath ?? null; + const projectRoot = useAppStore(selectActiveProjectRoot); const [persistedLaneState, setPersistedLaneState] = useState(() => readRunPageLaneState(projectRoot), diff --git a/apps/desktop/src/renderer/components/settings/LinearSection.tsx b/apps/desktop/src/renderer/components/settings/LinearSection.tsx index 2fbcd2313..24223cf97 100644 --- a/apps/desktop/src/renderer/components/settings/LinearSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LinearSection.tsx @@ -14,7 +14,7 @@ import type { CtoLinearProject, GitHubAutolink, LinearConnectionStatus } from ". import { ADE_DEEPLINK_HTTPS_BASE_URL } from "../../../shared/deeplinks"; import { COLORS, SANS_FONT, MONO_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; import { Button } from "../ui/Button"; -import { useAppStore } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; const LINEAR_BRAND = "#5E6AD2"; const LINEAR_API_SETTINGS_URL = "https://linear.app/settings/api"; @@ -41,7 +41,7 @@ export function LinearSection() { // project (credentials are project-scoped). Re-run the loaders whenever the // active project changes so the autolink commands target the right repo and // Linear workspace instead of a stale previously-loaded project. - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const [connection, setConnection] = useState(null); const [projects, setProjects] = useState([]); const [githubRepo, setGithubRepo] = useState<{ owner: string; name: string } | null>(null); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index 3bd736cec..9d66b2930 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -45,6 +45,13 @@ class MockIntersectionObserver { } vi.mock("../../state/appStore", () => ({ + selectActiveProjectRoot: (state: { + projectBinding?: { kind?: string; rootPath?: string | null } | null; + project?: { rootPath?: string | null } | null; + }) => { + if (state.projectBinding?.kind === "remote") return state.projectBinding.rootPath?.trim() || null; + return state.project?.rootPath?.trim() || null; + }, useAppStore: vi.fn((selector: (state: { theme: "dark"; terminalPreferences: { diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index fe1c61b43..dae81352a 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -6,6 +6,7 @@ import { cn } from "../ui/cn"; import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_PREFERENCES, + selectActiveProjectRoot, useAppStore, type TerminalPreferences, type ThemeId, @@ -1930,7 +1931,7 @@ export function TerminalView({ }) { const appTheme = useAppStore((s) => s.theme); const terminalPreferences = useAppStore((s) => s.terminalPreferences); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const projectRevision = useAppStore((s) => s.projectRevision); const runtimeProjectScopeRef = useRef<{ sessionId: string; diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx index 60888e81d..09cfc00a9 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx @@ -174,6 +174,13 @@ const sessionListPaneProps = vi.hoisted(() => ({ })); vi.mock("../../state/appStore", () => ({ + selectActiveProjectRoot: (state: { + projectBinding?: { kind?: string; rootPath?: string | null } | null; + project?: { rootPath?: string | null } | null; + }) => { + if (state.projectBinding?.kind === "remote") return state.projectBinding.rootPath?.trim() || null; + return state.project?.rootPath?.trim() || null; + }, useAppStore: (selector: (state: { selectedLaneId: string; project: { rootPath: string } | null }) => T): T => selector({ selectedLaneId: "lane-primary", diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 231f48f87..c90c1493e 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -12,7 +12,7 @@ import type { AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import { canBulkDeleteSession, canBulkStopSession, formatToolTypeLabel, isChatToolType } from "../../lib/sessions"; import { sortLanesForTabs } from "../lanes/laneUtils"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; -import { useAppStore, type WorkDraftKind } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore, type WorkDraftKind } from "../../state/appStore"; import { ADE_OPEN_BUILT_IN_BROWSER_EVENT } from "../../lib/openExternal"; import { ADE_WORK_SIDEBAR_BROWSER_RESIZE_END_EVENT, @@ -103,7 +103,7 @@ async function allSettledWithConcurrency( export function TerminalsPage({ active = true }: { active?: boolean }) { const work = useWorkSessions({ active }); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const selectedLaneId = useAppStore((s) => s.selectedLaneId); const sortedLanes = useMemo(() => sortLanesForTabs(work.lanes), [work.lanes]); diff --git a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx index f0d16a21b..59b1281b8 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx @@ -21,7 +21,7 @@ import type { TerminalSessionSummary, TerminalToolType, } from "../../../shared/types"; -import { useAppStore, type WorkDraftKind, type WorkSidebarTab } from "../../state/appStore"; +import { selectActiveProjectRoot, useAppStore, type WorkDraftKind, type WorkSidebarTab } from "../../state/appStore"; import { formatAppControlContextForPrompt, formatBuiltInBrowserContextForPrompt, @@ -213,7 +213,7 @@ export function WorkSidebar({ const [appControlSession, setAppControlSession] = useState(null); const [iosSession, setIosSession] = useState(null); const [browserStatus, setBrowserStatus] = useState(null); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const sidebarRef = useRef(null); const [compactTabs, setCompactTabs] = useState(false); diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index e45ceae79..ad87a5908 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -114,7 +114,13 @@ vi.mock("../../state/appStore", () => { subscribe: vi.fn(() => () => {}), }; const useAppStoreApi = () => appStoreApi; - return { useAppStore, useAppStoreApi }; + const selectActiveProjectRoot = (state: Record) => { + const binding = state.projectBinding as { kind?: string; rootPath?: string | null } | null | undefined; + if (binding?.kind === "remote") return binding.rootPath?.trim() || null; + const project = state.project as { rootPath?: string | null } | null | undefined; + return project?.rootPath?.trim() || null; + }; + return { selectActiveProjectRoot, useAppStore, useAppStoreApi }; }); // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index c5a1d0cc3..5744d3809 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import type { AgentChatSession, LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import { + selectActiveProjectRoot, useAppStore, useAppStoreApi, type WorkDraftKind, @@ -352,7 +353,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const location = useLocation(); const [searchParams] = useSearchParams(); const appStore = useAppStoreApi(); - const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRoot = useAppStore(selectActiveProjectRoot); const lanes = useAppStore((s) => s.lanes); const focusSession = useAppStore((s) => s.focusSession); const selectLane = useAppStore((s) => s.selectLane); diff --git a/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx b/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx index e71882cd3..d3357d722 100644 --- a/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx +++ b/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx @@ -6,6 +6,7 @@ import { FloatingPane } from "./FloatingPane"; import { useDockLayout } from "./DockLayoutState"; import { cn } from "./cn"; import { logRendererDebugEvent } from "../../lib/debugLog"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { collectLeafIds, detectDropEdge, @@ -74,6 +75,7 @@ export function PaneTilingLayout({ panes: Record; className?: string; }) { + const projectRoot = useAppStore(selectActiveProjectRoot); const { layout, loaded, saveLayout } = useDockLayout(layoutId, {}); /* ---- Mutable tree state (Phase D) ---- */ @@ -363,12 +365,13 @@ export function PaneTilingLayout({ lastReadyLogSignatureRef.current = signature; logRendererDebugEvent("renderer.pane_layout.ready", { layoutId, + projectRoot, loaded, treeLoaded, liveLeafCount, paneCount, }); - }, [layoutId, loaded, treeLoaded, liveLeafCount, paneCount]); + }, [layoutId, projectRoot, loaded, treeLoaded, liveLeafCount, paneCount]); const renderNode = ( node: PaneLeaf | PaneSplit, diff --git a/apps/desktop/src/renderer/lib/agentChatSessionListCache.ts b/apps/desktop/src/renderer/lib/agentChatSessionListCache.ts index f52555ed1..08a8b66c0 100644 --- a/apps/desktop/src/renderer/lib/agentChatSessionListCache.ts +++ b/apps/desktop/src/renderer/lib/agentChatSessionListCache.ts @@ -2,7 +2,7 @@ import type { AgentChatListArgs, AgentChatSessionSummary, } from "../../shared/types"; -import { useAppStore } from "../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../state/appStore"; type CacheEntry = { value?: AgentChatSessionSummary[]; @@ -22,9 +22,7 @@ function normalizeArgs(args?: AgentChatListArgs): AgentChatListArgs { } function activeProjectRoot(): string | null { - const state = useAppStore.getState(); - if (state.projectBinding?.kind === "remote") return state.projectBinding.rootPath?.trim() || null; - return state.project?.rootPath?.trim() || null; + return selectActiveProjectRoot(useAppStore.getState()); } function cacheKey(args?: AgentChatListArgs): string { diff --git a/apps/desktop/src/renderer/lib/sessionListCache.ts b/apps/desktop/src/renderer/lib/sessionListCache.ts index 6c566e5a1..727b56aa1 100644 --- a/apps/desktop/src/renderer/lib/sessionListCache.ts +++ b/apps/desktop/src/renderer/lib/sessionListCache.ts @@ -1,5 +1,5 @@ import type { ListSessionsArgs, TerminalSessionSummary } from "../../shared/types"; -import { useAppStore } from "../state/appStore"; +import { selectActiveProjectRoot, useAppStore } from "../state/appStore"; type SessionToolType = NonNullable[number]; @@ -40,7 +40,7 @@ function normalizeArgs(args?: ListSessionsArgs): ListSessionsArgs { function cacheKey(args?: ListSessionsArgs): string { const normalized = normalizeArgs(args); return JSON.stringify({ - projectRoot: useAppStore.getState().project?.rootPath?.trim() || null, + projectRoot: selectActiveProjectRoot(useAppStore.getState()), laneId: normalized.laneId ?? null, status: normalized.status ?? null, toolTypes: normalized.toolTypes ?? null, diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index c2f9e336c..76e591afa 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -802,6 +802,13 @@ export type AppState = { closeProject: () => Promise; }; +export function selectActiveProjectRoot(state: Pick): string | null { + const root = state.projectBinding?.kind === "remote" + ? state.projectBinding.rootPath + : state.project?.rootPath; + return root?.trim() || null; +} + export type LaneInspectorTab = "terminals" | "context" | "stack" | "merge"; type LaneRefreshRequest = { @@ -948,7 +955,7 @@ const createAppState: StateCreator = (set, get) => { setProject: (project) => set((prev) => { - const previousProjectRoot = prev.project?.rootPath ?? null; + const previousProjectRoot = selectActiveProjectRoot(prev); const nextProjectRoot = project?.rootPath ?? null; const projectChanged = previousProjectRoot !== nextProjectRoot; const warmLaneCache = projectChanged && nextProjectRoot @@ -1028,7 +1035,7 @@ const createAppState: StateCreator = (set, get) => { })), selectLane: (laneId) => set((prev) => { - const projectRoot = prev.project?.rootPath ?? null; + const projectRoot = selectActiveProjectRoot(prev); if (!projectRoot) return { selectedLaneId: laneId }; const previousSelection = prev.laneSelectionByProject[projectRoot] ?? { laneId: null, sessionId: null }; @@ -1054,7 +1061,7 @@ const createAppState: StateCreator = (set, get) => { }), focusSession: (sessionId) => set((prev) => { - const projectRoot = prev.project?.rootPath ?? null; + const projectRoot = selectActiveProjectRoot(prev); if (!projectRoot) return { focusedSessionId: sessionId }; const previousSelection = prev.laneSelectionByProject[projectRoot] ?? { laneId: null, sessionId: null }; @@ -1263,7 +1270,7 @@ const createAppState: StateCreator = (set, get) => { refreshLanes: async (options) => { const request = normalizeLaneRefreshRequest(options); const runRefresh = async (currentRequest: LaneRefreshRequest) => { - const requestedProjectKey = normalizeProjectKey(get().project?.rootPath); + const requestedProjectKey = normalizeProjectKey(selectActiveProjectRoot(get())); activeLaneRefreshProjectKey = requestedProjectKey; const token = ++laneRefreshVersion; const previousLanesById = new Map(get().lanes.map((lane) => [lane.id, lane] as const)); @@ -1296,7 +1303,7 @@ const createAppState: StateCreator = (set, get) => { if (token !== laneRefreshVersion) { return; } - const projectKey = normalizeProjectKey(get().project?.rootPath); + const projectKey = normalizeProjectKey(selectActiveProjectRoot(get())); if (projectKey !== requestedProjectKey) { return; } @@ -1334,7 +1341,7 @@ const createAppState: StateCreator = (set, get) => { }); // Cache the lane list per project root so the next switch back to this // project can apply lanes immediately (no flicker, no chat unmount). - const activeProjectRoot = get().project?.rootPath ?? null; + const activeProjectRoot = selectActiveProjectRoot(get()); const nextLaneCache = activeProjectRoot ? { ...prev.laneCacheByProject, @@ -1365,7 +1372,7 @@ const createAppState: StateCreator = (set, get) => { if (laneRefreshInFlight) { const activeRequest = activeLaneRefreshRequest; const activeProjectKey = activeLaneRefreshProjectKey; - const requestProjectKey = normalizeProjectKey(get().project?.rootPath); + const requestProjectKey = normalizeProjectKey(selectActiveProjectRoot(get())); const activeSatisfies = activeRequest != null && activeProjectKey === requestProjectKey @@ -1403,7 +1410,7 @@ const createAppState: StateCreator = (set, get) => { }, refreshProviderMode: async () => { - const projectRoot = get().project?.rootPath ?? null; + const projectRoot = selectActiveProjectRoot(get()); const [snapshot, aiStatus] = await Promise.all([ getProjectConfigCached({ projectRoot }), getAiStatusCached({ projectRoot }).catch(() => null), @@ -1416,7 +1423,7 @@ const createAppState: StateCreator = (set, get) => { refreshKeybindings: async () => { const keybindings = await getKeybindingsCoalesced({ - projectRoot: get().project?.rootPath ?? null, + projectRoot: selectActiveProjectRoot(get()), }); set({ keybindings }); }, @@ -1503,7 +1510,7 @@ const createAppState: StateCreator = (set, get) => { ++laneRefreshVersion; // Stash the OUTGOING project's lane/session selection so switching back // restores it instead of falling through to "first lane". - const outgoingProjectRoot = get().project?.rootPath ?? null; + const outgoingProjectRoot = selectActiveProjectRoot(get()); const outgoingSelection = { laneId: get().selectedLaneId, sessionId: get().focusedSessionId, @@ -1622,7 +1629,7 @@ const createAppState: StateCreator = (set, get) => { window.setTimeout(() => { void window.ade.project.listRecent().then((recentRows) => { const recentRoots = new Set(recentRows.map((r: { rootPath: string }) => r.rootPath)); - const activeRoot = get().project?.rootPath ?? null; + const activeRoot = selectActiveProjectRoot(get()); const openProjectRoots = new Set(get().openProjectTabRoots); const retainedRootSet = new Set(); for (const root of [activeRoot, ...recentRoots, ...openProjectRoots]) { @@ -1761,7 +1768,7 @@ const createAppState: StateCreator = (set, get) => { }, closeProject: async () => { - const closingProjectRoot = get().project?.rootPath ?? null; + const closingProjectRoot = selectActiveProjectRoot(get()); ++laneRefreshVersion; set({ projectTransition: { From 4728b56cdd83793aa34cbdc980c8beb7e6e3e8f7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:01:59 -0400 Subject: [PATCH 35/80] fix(work): defer remote recovery checks --- .../components/chat/AgentChatPane.test.tsx | 40 ++++++ .../components/chat/AgentChatPane.tsx | 123 +++++++++++------- 2 files changed, 113 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 0f73cb893..19e31c5bd 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -995,6 +995,10 @@ function sessionTabTitles(expectedTitles: string[]) { } describe("AgentChatPane remote startup", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("uses the remote binding root for AI status cache lookups", async () => { const session = buildSession("session-1", { status: "idle" }); installAdeMocks({ sessions: [session] }); @@ -1038,6 +1042,42 @@ describe("AgentChatPane remote startup", () => { expect(window.ade.sessions.getDelta).not.toHaveBeenCalled(); }); + it("defers unfinished parallel launch recovery on remote draft mount", async () => { + vi.useFakeTimers(); + const { parallelLaunchStateGet } = installAdeMocks({ sessions: [] }); + seedRemoteChatStore(); + + render( + + + , + ); + + await act(async () => { + await Promise.resolve(); + }); + expect(parallelLaunchStateGet).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(14_999); + await Promise.resolve(); + }); + expect(parallelLaunchStateGet).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + }); + expect(parallelLaunchStateGet).toHaveBeenCalledWith({ + projectRoot: "/Users/admin/Projects/perf pass", + parentLaneId: "lane-1", + }); + }); + it("fetches remote session delta after a turn completes", async () => { const session = buildSession("session-1", { status: "idle" }); const mocks = installAdeMocks({ sessions: [session] }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 4415f2612..5d21d0be8 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -188,6 +188,7 @@ const COMPOSER_DRAFT_WRITE_DEBOUNCE_MS = 350; const SUBAGENT_AUTOOPEN_FIRED_KEY_PREFIX = "ade.chat.subagentAutoOpenFired"; const SUBAGENT_AUTOOPEN_FIRED_TTL_MS = 7 * 24 * 60 * 60 * 1000; const workCliStartupDelayMs = 180; +const REMOTE_PARALLEL_LAUNCH_RECOVERY_DELAY_MS = 15_000; export const DEFAULT_PARALLEL_ATTACHMENT_REQUEST = "Please review the attached files."; const chatToolbarActionBase = @@ -3138,67 +3139,80 @@ export function AgentChatPane({ if (recoveredParallelLaunchKeyRef.current === recoveryKey) return; recoveredParallelLaunchKeyRef.current = recoveryKey; let cancelled = false; + let recoveryTimer: number | null = null; - void (async () => { - let pendingState: AgentChatParallelLaunchState | null = null; - try { - pendingState = await window.ade.agentChat.parallelLaunchState.get({ - projectRoot, - parentLaneId: laneId, - }); - } catch { - return; - } - if (!pendingState) return; - if (isCompletedParallelLaunchState(pendingState)) { - await persistParallelLaunchState(null); - return; - } + const recoverParallelLaunchState = () => { + void (async () => { + let pendingState: AgentChatParallelLaunchState | null = null; + try { + pendingState = await window.ade.agentChat.parallelLaunchState.get({ + projectRoot, + parentLaneId: laneId, + }); + } catch { + return; + } + if (!pendingState) return; + if (isCompletedParallelLaunchState(pendingState)) { + await persistParallelLaunchState(null); + return; + } - if (!pendingState.createdLaneIds.length) { - await persistParallelLaunchState(null); - return; - } + if (!pendingState.createdLaneIds.length) { + await persistParallelLaunchState(null); + return; + } - if (cancelled) return; - setParallelLaunchBusy(true); - setParallelLaunchStatus("Cleaning up unfinished parallel launch…"); - const cleanupIssues = await cleanupTransientParallelLaunchLanes({ - laneIds: pendingState.createdLaneIds, - deleteLane: (args) => window.ade.lanes.delete(args), - refreshLanes: refreshLanesStore, - onCleanupError: logParallelLaunchCleanupError, - }); + if (cancelled) return; + setParallelLaunchBusy(true); + setParallelLaunchStatus("Cleaning up unfinished parallel launch…"); + const cleanupIssues = await cleanupTransientParallelLaunchLanes({ + laneIds: pendingState.createdLaneIds, + deleteLane: (args) => window.ade.lanes.delete(args), + refreshLanes: refreshLanesStore, + onCleanupError: logParallelLaunchCleanupError, + }); - if (cleanupIssues.length === 0) { - await persistParallelLaunchState(null); - } else { - await persistParallelLaunchState(buildParallelLaunchState({ - parentLaneId: pendingState.parentLaneId, - createdLaneIds: pendingState.createdLaneIds, - sentLaneIds: pendingState.sentLaneIds, - status: "cleanup_pending", - lastError: pendingState.lastError, - })); - if (!cancelled) { - setError(formatParallelLaunchFailureMessage({ - launchError: "Recovered an unfinished parallel launch from before ADE closed.", - cleanupIssues, + if (cleanupIssues.length === 0) { + await persistParallelLaunchState(null); + } else { + await persistParallelLaunchState(buildParallelLaunchState({ + parentLaneId: pendingState.parentLaneId, + createdLaneIds: pendingState.createdLaneIds, + sentLaneIds: pendingState.sentLaneIds, + status: "cleanup_pending", + lastError: pendingState.lastError, })); + if (!cancelled) { + setError(formatParallelLaunchFailureMessage({ + launchError: "Recovered an unfinished parallel launch from before ADE closed.", + cleanupIssues, + })); + } } - } - if (!cancelled) { - setParallelLaunchBusy(false); - setParallelLaunchStatus(null); - } - })(); + if (!cancelled) { + setParallelLaunchBusy(false); + setParallelLaunchStatus(null); + } + })(); + }; + + if (isRemoteProject) { + recoveryTimer = window.setTimeout(recoverParallelLaunchState, REMOTE_PARALLEL_LAUNCH_RECOVERY_DELAY_MS); + } else { + recoverParallelLaunchState(); + } return () => { cancelled = true; + if (recoveryTimer != null) { + window.clearTimeout(recoveryTimer); + } }; }, [ initialSessionId, + isRemoteProject, laneId, lockSessionId, persistParallelLaunchState, @@ -4723,6 +4737,7 @@ export function AgentChatPane({ const sessionDeltaTurnActiveRef = useRef(false); const sessionDeltaSessionIdRef = useRef(null); + const remoteDeltaArmedSessionsRef = useRef>(new Set()); // Fetch git diff stats when the session changes or a turn completes. Remote // chats skip the mount-time decoration fetch; the bridge should stay focused @@ -4734,11 +4749,16 @@ export function AgentChatPane({ sessionDeltaSessionIdRef.current = selectedSessionId; sessionDeltaTurnActiveRef.current = turnActive; if (isRemoteProject) { - const completedTurn = sameSession && previousTurnActive && !turnActive; + const completedTurn = + remoteDeltaArmedSessionsRef.current.has(selectedSessionId) + && sameSession + && previousTurnActive + && !turnActive; if (!completedTurn) { if (!turnActive) setSessionDelta(null); return; } + remoteDeltaArmedSessionsRef.current.delete(selectedSessionId); } let cancelled = false; const fetchDelta = () => { @@ -4879,6 +4899,9 @@ export function AgentChatPane({ envelope.event.type === "user_message" || (envelope.event.type === "status" && envelope.event.turnStatus === "started") ) { + if (isRemoteProject && envelope.event.type === "status") { + remoteDeltaArmedSessionsRef.current.add(envelope.sessionId); + } patchSessionSummary(envelope.sessionId, { status: "active", idleSinceAt: null, @@ -4986,7 +5009,7 @@ export function AgentChatPane({ } }); return unsubscribe; - }, [isTileVisible, layoutVariant, lockSessionId, flushQueuedEvents, patchSessionSummary, scheduleQueuedEventFlush, scheduleSessionsRefresh, touchSession]); + }, [isRemoteProject, isTileVisible, layoutVariant, lockSessionId, flushQueuedEvents, patchSessionSummary, scheduleQueuedEventFlush, scheduleSessionsRefresh, touchSession]); useEffect(() => { if (!isTileActive) return undefined; From e78d2233c0980bcee6fed69665bb82c7edaaf1f9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:05:19 -0400 Subject: [PATCH 36/80] fix(remote): defer hidden chrome hydration --- .../src/renderer/components/app/TopBar.tsx | 9 +++++++-- .../onboarding/OnboardingBootstrap.tsx | 6 ++++-- .../components/usage/HeaderUsageControl.tsx | 14 ++++++++++--- .../renderer/components/usage/usage.test.tsx | 20 +++++++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 9b207a699..de1b554bf 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1779,7 +1779,11 @@ export function TopBar() { return (
- + {remoteChip} {mobileChip}
@@ -1791,12 +1795,13 @@ export function TopBar() { {remoteChip} {mobileChip} - + ); }, [ phoneSyncOpen, + remoteBinding, remoteConnected, remotePanelOpen, showSyncControl, diff --git a/apps/desktop/src/renderer/components/onboarding/OnboardingBootstrap.tsx b/apps/desktop/src/renderer/components/onboarding/OnboardingBootstrap.tsx index e895ca28a..886156b2f 100644 --- a/apps/desktop/src/renderer/components/onboarding/OnboardingBootstrap.tsx +++ b/apps/desktop/src/renderer/components/onboarding/OnboardingBootstrap.tsx @@ -18,6 +18,7 @@ export function OnboardingBootstrap() { const showWelcome = useAppStore((s) => s.showWelcome); const isNewTabOpen = useAppStore((s) => s.isNewTabOpen); const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); const hydrated = useOnboardingStore((s) => s.hydrated); const hydrate = useOnboardingStore((s) => s.hydrate); @@ -35,13 +36,14 @@ export function OnboardingBootstrap() { const workTourAutoFiredRef = useRef(false); const filesTourAutoFiredRef = useRef(false); const runTourAutoFiredRef = useRef(false); + const isRemoteProject = projectBinding?.kind === "remote"; // Hydrate once. useEffect(() => { - if (!hydrated) { + if (!hydrated && !isRemoteProject) { void hydrate(); } - }, [hydrated, hydrate]); + }, [hydrated, hydrate, isRemoteProject]); const hasActiveProject = projectHydrated === true && diff --git a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx index 7a4e6f7f6..464634d14 100644 --- a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx +++ b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx @@ -122,9 +122,11 @@ function HeaderProviderUsageChip({ export function HeaderUsageControl({ variant = "chip", onMenuActivate, + deferInitialRead = false, }: { variant?: "chip" | "menu-row"; onMenuActivate?: () => void; + deferInitialRead?: boolean; } = {}) { const [open, setOpen] = useState(false); const [snapshot, setSnapshot] = useState(null); @@ -179,18 +181,24 @@ export function HeaderUsageControl({ }); }; - readCachedSnapshot(); + if (!deferInitialRead || open) { + readCachedSnapshot(); + } return () => { cancelled = true; unsubscribe?.(); }; - }, [applySnapshot]); + }, [applySnapshot, deferInitialRead, open]); // Fetch provider connection status so the header only shows configured // Claude/Codex usage meters. useEffect(() => { if (!window.ade?.ai?.getStatus) return; + if (deferInitialRead && !open) { + setProviderConnections(undefined); + return; + } const aiBridge = window.ade.ai; let cancelled = false; const loadProviderStatus = () => { @@ -208,7 +216,7 @@ export function HeaderUsageControl({ cancelled = true; window.clearInterval(interval); }; - }, []); + }, [deferInitialRead, open]); const detectedProviders = useMemo(() => { const providersWithUsage = TRACKED_PROVIDERS.filter((provider) => diff --git a/apps/desktop/src/renderer/components/usage/usage.test.tsx b/apps/desktop/src/renderer/components/usage/usage.test.tsx index 46d4ae723..9b1193a86 100644 --- a/apps/desktop/src/renderer/components/usage/usage.test.tsx +++ b/apps/desktop/src/renderer/components/usage/usage.test.tsx @@ -303,6 +303,26 @@ describe("usage components", () => { expect(window.ade.usage.refresh).not.toHaveBeenCalled(); }); + it("defers the cached usage and provider reads until opened", async () => { + vi.mocked(window.ade.usage.getSnapshot).mockResolvedValue(makeHeaderUsageSnapshot()); + + render(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(window.ade.usage.getSnapshot).not.toHaveBeenCalled(); + expect(window.ade.ai.getStatus).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Usage" })); + + await waitFor(() => { + expect(window.ade.usage.getSnapshot).toHaveBeenCalled(); + expect(window.ade.ai.getStatus).toHaveBeenCalled(); + }); + }); + it("applies pushed usage updates without forcing a refresh", async () => { let onUpdate: ((snapshot: UsageSnapshot) => void) | null = null; vi.mocked(window.ade.usage.onUpdate).mockImplementation((cb) => { From 612999ecfb12e4f5773eecfd58611c0a512fd5d1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:13:54 -0400 Subject: [PATCH 37/80] fix(remote): skip event replay on attach --- .../main/services/ipc/runtimeBridge.test.ts | 39 +++++++++++++++++++ .../src/main/services/ipc/runtimeBridge.ts | 29 +++++++++----- .../localRuntimeConnectionPool.ts | 1 + .../remoteConnectionPool.test.ts | 2 + .../remoteRuntime/remoteConnectionPool.ts | 1 + apps/desktop/src/preload/preload.test.ts | 27 +++++-------- apps/desktop/src/preload/preload.ts | 7 ++++ .../src/renderer/components/app/AppShell.tsx | 4 +- .../desktop/src/shared/types/remoteRuntime.ts | 1 + 9 files changed, 82 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index aedf80429..bd17dbb3f 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -22,6 +22,8 @@ const remoteCallSyncForTargetMock = vi.hoisted(() => vi.fn()); const remoteEnsureLocalPortForwardMock = vi.hoisted(() => vi.fn()); const remoteListActionRegistryForTargetMock = vi.hoisted(() => vi.fn()); const remoteCallMachineForTargetMock = vi.hoisted(() => vi.fn()); +const remoteStreamEventsForTargetMock = vi.hoisted(() => vi.fn()); +const remoteSubscribeEventsForTargetMock = vi.hoisted(() => vi.fn()); const remoteDisconnectMock = vi.hoisted(() => vi.fn()); const hasKnownSshHostKeyForTargetMock = vi.hoisted(() => vi.fn(() => false)); const getSshHostKeyTrustForTargetMock = vi.hoisted(() => vi.fn()); @@ -86,6 +88,8 @@ vi.mock("../remoteRuntime/remoteConnectionPool", () => ({ ensureLocalPortForward: remoteEnsureLocalPortForwardMock, listActionRegistryForTarget: remoteListActionRegistryForTargetMock, callMachineForTarget: remoteCallMachineForTargetMock, + streamEventsForTarget: remoteStreamEventsForTargetMock, + subscribeEventsForTarget: remoteSubscribeEventsForTargetMock, disconnect: remoteDisconnectMock, onEntryEvicted: vi.fn(() => () => {}), })), @@ -168,6 +172,8 @@ describe("registerRuntimeBridge", () => { remoteEnsureLocalPortForwardMock.mockReset(); remoteListActionRegistryForTargetMock.mockReset(); remoteCallMachineForTargetMock.mockReset(); + remoteStreamEventsForTargetMock.mockReset(); + remoteSubscribeEventsForTargetMock.mockReset().mockResolvedValue(vi.fn()); remoteDisconnectMock.mockReset(); hasKnownSshHostKeyForTargetMock.mockReset().mockReturnValue(false); getSshHostKeyTrustForTargetMock.mockReset().mockResolvedValue({ @@ -648,6 +654,39 @@ describe("registerRuntimeBridge", () => { ); }); + it("opens remote event subscriptions without replaying buffered history", async () => { + remoteRegistryGetMock.mockReturnValue(target); + remoteStreamEventsForTargetMock.mockResolvedValue({ + events: [{ id: 1, timestamp: "now", category: "runtime", payload: { type: "stale" } }], + nextCursor: 1, + hasMore: false, + }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeStreamEvents)?.( + eventForSender(sender(202)), + { + id: "target-1", + projectId: "project-1", + request: { cursor: 0, limit: 100, replay: false }, + }, + ), + ).resolves.toEqual({ events: [], nextCursor: 0, hasMore: false }); + + expect(remoteStreamEventsForTargetMock).not.toHaveBeenCalled(); + expect(remoteSubscribeEventsForTargetMock).toHaveBeenCalledWith( + target, + "project-1", + { cursor: 0, limit: 100, category: undefined, replay: false }, + expect.any(Function), + expect.any(Function), + ); + }); + it("rejects unexposed sync methods before calling local or remote runtimes", async () => { const localRuntimeConnectionPool = { callSyncForRoot: vi.fn(), diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index fc1ddf301..0779d1544 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -942,7 +942,7 @@ export function registerRuntimeBridge({ ensureRuntimeEventSubscription( event.sender, bindingKey, - `${bindingKey}:${arg?.request?.category ?? "*"}`, + `${bindingKey}:${arg?.request?.category ?? "*"}:${arg?.request?.replay === false ? "live" : "replay"}`, (onEvent, onEnded) => localRuntimeConnectionPool.subscribeEventsForRoot( rootPath, @@ -950,6 +950,7 @@ export function registerRuntimeBridge({ cursor: arg?.request?.cursor, limit: arg?.request?.limit, category: arg?.request?.category, + replay: arg?.request?.replay, }, onEvent, onEnded, @@ -985,23 +986,31 @@ export function registerRuntimeBridge({ if (!projectId) throw new Error("Remote project id is required."); const target = remoteConnectionService.getTarget(id); if (!target) throw new Error("Remote target was not found."); - const result = await remoteConnectionService.streamEvents( - target.id, - projectId, - arg?.request ?? {}, - ); + const request = arg?.request ?? {}; + const result = request.replay === false + ? { + events: [], + nextCursor: request.cursor ?? 0, + hasMore: false, + } + : await remoteConnectionService.streamEvents( + target.id, + projectId, + request, + ); ensureRuntimeEventSubscription( event.sender, `remote:${target.id}:${projectId}`, - `remote:${target.id}:${projectId}:${arg?.request?.category ?? "*"}`, + `remote:${target.id}:${projectId}:${request.category ?? "*"}:${request.replay === false ? "live" : "replay"}`, (onEvent, onEnded) => remoteConnectionPool.subscribeEventsForTarget( target, projectId, { - cursor: arg?.request?.cursor, - limit: arg?.request?.limit, - category: arg?.request?.category, + cursor: request.cursor, + limit: request.limit, + category: request.category, + replay: request.replay, }, onEvent, onEnded, diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 8846f2889..8e31e346b 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -1435,6 +1435,7 @@ async function subscribeToRuntimeEvents( cursor: clampCursor(request.cursor), limit: clampLimit(request.limit), ...(isRemoteRuntimeEventCategory(request.category) ? { category: request.category } : {}), + ...(typeof request.replay === "boolean" ? { replay: request.replay } : {}), }); subscriptionId = readSubscriptionId(value); for (const notification of pendingNotifications) { diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts index 2e00a7697..540e46bc7 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts @@ -1020,6 +1020,7 @@ describe("RemoteConnectionPool", () => { cursor: 5, limit: 10, category: "pty", + replay: false, }, onEvent, ); @@ -1029,6 +1030,7 @@ describe("RemoteConnectionPool", () => { cursor: 5, limit: 10, category: "pty", + replay: false, }); expect(onEvent).toHaveBeenCalledTimes(1); expect(onEvent).toHaveBeenCalledWith({ diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts index cc5f661da..b3a710941 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts @@ -1006,6 +1006,7 @@ async function subscribeToRuntimeEvents( ...(isRemoteRuntimeEventCategory(request.category) ? { category: request.category } : {}), + ...(typeof request.replay === "boolean" ? { replay: request.replay } : {}), }); subscriptionId = readSubscriptionId(value); for (const notification of pendingNotifications) { diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index f23ce8e47..18ff200db 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -4104,7 +4104,7 @@ describe("preload OAuth bridge", () => { expect(usageUpdate).toHaveBeenCalledTimes(1); }); - it("replays older remote runtime events during remote binding catch-up", async () => { + it("starts remote runtime event subscriptions without replaying buffered history", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-10T12:00:00.000Z")); try { @@ -4128,15 +4128,8 @@ describe("preload OAuth bridge", () => { } if (channel === IPC.remoteRuntimeStreamEvents) { return { - events: [ - { - id: 1, - timestamp: "2026-05-10T11:55:00.000Z", - category: "runtime", - payload: { type: "project_state_event", event: projectEvent }, - }, - ], - nextCursor: 1, + events: [], + nextCursor: 0, hasMore: false, }; } @@ -4170,9 +4163,9 @@ describe("preload OAuth bridge", () => { expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeStreamEvents, { id: "target-1", projectId: "project-1", - request: { cursor: 0, limit: 100 }, + request: { cursor: 0, limit: 100, replay: false }, }); - expect(callback).toHaveBeenCalledWith(projectEvent); + expect(callback).not.toHaveBeenCalledWith(projectEvent); unsubscribe(); } finally { @@ -4354,7 +4347,7 @@ describe("preload OAuth bridge", () => { expect(streamRequests[1]).toEqual({ id: "target-2", projectId: "project-2", - request: { cursor: 0, limit: 100 }, + request: { cursor: 0, limit: 100, replay: false }, }); unsubscribeBinding(); @@ -4462,7 +4455,7 @@ describe("preload OAuth bridge", () => { { id: "target-1", projectId: "project-1", - request: { cursor: 0, limit: 100 }, + request: { cursor: 0, limit: 100, replay: false }, }, { id: "target-1", @@ -4472,7 +4465,7 @@ describe("preload OAuth bridge", () => { { id: "target-1", projectId: "project-1", - request: { cursor: 0, limit: 100 }, + request: { cursor: 0, limit: 100, replay: false }, }, ]); expect(callback).toHaveBeenCalledWith(oldEvent); @@ -4581,7 +4574,7 @@ describe("preload OAuth bridge", () => { { id: "target-1", projectId: "project-1", - request: { cursor: 0, limit: 100 }, + request: { cursor: 0, limit: 100, replay: false }, }, { id: "target-1", @@ -4591,7 +4584,7 @@ describe("preload OAuth bridge", () => { { id: "target-1", projectId: "project-1", - request: { cursor: 0, limit: 100 }, + request: { cursor: 0, limit: 100, replay: false }, }, ]); expect(callback).toHaveBeenCalledWith(oldEvent); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 1f6b7632f..bf7683d22 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1632,6 +1632,7 @@ let remoteRuntimeEventBindingKey: string | null = null; let remoteRuntimeEventGeneration = -1; let remoteRuntimeEventEpoch: string | null = null; let remoteRuntimeEventStartedAtMs = 0; +let remoteRuntimeEventReplaySuppressed = false; let remoteRuntimeEmptyPollCount = 0; let remoteRuntimeSeenEventBindingKey: string | null = null; const remoteRuntimeSeenEventIds = new Set(); @@ -1767,6 +1768,7 @@ async function pollRemoteRuntimeEvents(): Promise { remoteRuntimeEventGeneration = projectBindingGeneration; remoteRuntimeEventEpoch = null; remoteRuntimeEventStartedAtMs = 0; + remoteRuntimeEventReplaySuppressed = false; resetRemoteRuntimeEmptyPolls(); resetRemoteRuntimeEventDedup(null); return; @@ -1782,6 +1784,7 @@ async function pollRemoteRuntimeEvents(): Promise { remoteRuntimeEventEpoch = null; remoteRuntimeEventStartedAtMs = binding.kind === "local" ? Date.now() : 0; + remoteRuntimeEventReplaySuppressed = binding.kind === "remote"; resetRemoteRuntimeEmptyPolls(); resetRemoteRuntimeEventDedup(binding.key); } @@ -1791,6 +1794,9 @@ async function pollRemoteRuntimeEvents(): Promise { const request = { cursor: remoteRuntimeEventCursor, limit: 100, + ...(binding.kind === "remote" && remoteRuntimeEventReplaySuppressed && remoteRuntimeEventCursor === 0 + ? { replay: false } + : {}), } satisfies RemoteRuntimeStreamEventsRequest; const batch = binding.kind === "remote" @@ -1823,6 +1829,7 @@ async function pollRemoteRuntimeEvents(): Promise { remoteRuntimeEventEpoch = batchEpoch; if (epochChanged) { remoteRuntimeEventCursor = 0; + remoteRuntimeEventReplaySuppressed = binding.kind === "remote"; resetRemoteRuntimeEmptyPolls(); resetRemoteRuntimeEventDedup(binding.key); nextDelayMs = 0; diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 2e3472ac0..0d273c5e5 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -687,7 +687,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { useEffect(() => { let cancelled = false; - if (!project?.rootPath || showWelcome) { + if (!project?.rootPath || showWelcome || isRemoteProject) { setOnboardingStatus(null); setOnboardingStatusLoading(false); return () => { @@ -712,7 +712,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { return () => { cancelled = true; }; - }, [project?.rootPath, showWelcome]); + }, [isRemoteProject, project?.rootPath, showWelcome]); useEffect(() => { const handler = (event: Event) => { diff --git a/apps/desktop/src/shared/types/remoteRuntime.ts b/apps/desktop/src/shared/types/remoteRuntime.ts index abded4cdd..41f1b3c37 100644 --- a/apps/desktop/src/shared/types/remoteRuntime.ts +++ b/apps/desktop/src/shared/types/remoteRuntime.ts @@ -195,6 +195,7 @@ export type RemoteRuntimeStreamEventsRequest = { cursor?: number; limit?: number; category?: RemoteRuntimeEventCategory; + replay?: boolean; }; export type RemoteRuntimeStreamEventsResult = { From 7cbbbf69205d1da636efc2b4b73655eefceaf213 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:23:25 -0400 Subject: [PATCH 38/80] fix(remote): tolerate optional files capabilities --- .../remoteConnectionPool.test.ts | 48 ++++++++++ .../remoteRuntime/remoteConnectionPool.ts | 90 +++++++++++++++++-- .../renderer/components/app/TopBar.test.tsx | 1 + .../src/renderer/components/app/TopBar.tsx | 11 +-- 4 files changed, 139 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts index 540e46bc7..a1e890752 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts @@ -762,6 +762,54 @@ describe("RemoteConnectionPool", () => { }, { timeoutMs: 25_000 }); }); + it("falls back to empty git decorations when an older runtime lacks that optional action", async () => { + const client = createClient(); + client.call.mockRejectedValueOnce(new Error("Action 'file.refreshGitDecorations' is not callable.")); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client, + ssh: createSsh(), + result: connectResult("0.9.0"), + }); + const pool = new RemoteConnectionPool({ get: () => null } as unknown as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "file", + action: "refreshGitDecorations", + args: { workspaceId: "primary", forceFresh: true }, + }), + ).resolves.toEqual({ + domain: "file", + action: "refreshGitDecorations", + result: { workspaceId: "primary", files: [], directories: [] }, + statusHints: { optionalActionMissing: true }, + }); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "file", + action: "refreshGitDecorations", + args: { workspaceId: "primary", forceFresh: true }, + }), + ).resolves.toEqual({ + domain: "file", + action: "refreshGitDecorations", + result: { workspaceId: "primary", files: [], directories: [] }, + statusHints: { optionalActionMissing: true }, + }); + + expect(client.call).toHaveBeenCalledTimes(1); + expect(client.call).toHaveBeenCalledWith("ade/actions/call", { + projectId: "project-1", + name: "run_ade_action", + arguments: { + domain: "file", + action: "refreshGitDecorations", + args: { workspaceId: "primary", forceFresh: true }, + }, + }, { timeoutMs: 25_000 }); + }); + it("lists remote ADE actions as grouped registry entries", async () => { const client = createClient(); client.call.mockResolvedValueOnce({ diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts index b3a710941..534f1f2b9 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts @@ -204,12 +204,56 @@ function assertMachineProjectCapability(entry: PoolEntry, method: string): void ); } +function optionalRemoteActionFallbackResult( + request: RemoteRuntimeActionRequest, +): RemoteRuntimeActionResult | null { + if (request.domain !== "file" || request.action !== "refreshGitDecorations") { + return null; + } + const args = request.args && typeof request.args === "object" && !Array.isArray(request.args) + ? request.args as Record + : {}; + const workspaceId = typeof args.workspaceId === "string" && args.workspaceId.trim() + ? args.workspaceId.trim() + : "primary"; + return { + domain: "file", + action: "refreshGitDecorations", + result: { + workspaceId, + files: [], + directories: [], + }, + statusHints: { + optionalActionMissing: true, + }, + }; +} + +function isRemoteActionNotCallableMessage(message: string): boolean { + return /not callable|not exposed|not available|unknown action/i.test(message); +} + +function isRemoteActionNotCallableError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return isRemoteActionNotCallableMessage(message); +} + +function unsupportedOptionalActionKey( + targetId: string, + projectId: string, + request: RemoteRuntimeActionRequest, +): string { + return `${targetId}\0${projectId}\0${request.domain}.${request.action}`; +} + export class RemoteConnectionPool { private readonly entries = new Map>(); private readonly localPortForwards = new Map< string, Promise >(); + private readonly unsupportedOptionalActionKeys = new Set(); private readonly pendingDisconnects = new Set(); private readonly resolvedEntryPromises = new Set>(); private readonly connectFailureBackoffByTargetId = new Map< @@ -282,6 +326,7 @@ export class RemoteConnectionPool { let entryPromise: Promise; entryPromise = pending.then(({ client, ssh, result }) => { const entry = { client, ssh, result }; + this.clearUnsupportedOptionalActionsForTarget(target.id); this.connectFailureBackoffByTargetId.delete(target.id); this.resolvedEntryPromises.add(entryPromise); this.attachEntryLifecycle(target.id, entryPromise, entry); @@ -528,6 +573,17 @@ export class RemoteConnectionPool { projectId: string, request: RemoteRuntimeActionRequest, ): Promise { + const optionalFallback = optionalRemoteActionFallbackResult(request); + const optionalFallbackKey = optionalFallback + ? unsupportedOptionalActionKey(entry.result.target.id, projectId, request) + : null; + if ( + optionalFallback && + optionalFallbackKey && + this.unsupportedOptionalActionKeys.has(optionalFallbackKey) + ) { + return optionalFallback; + } const params = { projectId, name: "run_ade_action", @@ -542,9 +598,18 @@ export class RemoteConnectionPool { }, }; const callOptions = remoteRuntimeActionCallOptions(request); - const value = callOptions - ? await entry.client.call("ade/actions/call", params, callOptions) - : await entry.client.call("ade/actions/call", params); + let value: unknown; + try { + value = callOptions + ? await entry.client.call("ade/actions/call", params, callOptions) + : await entry.client.call("ade/actions/call", params); + } catch (error) { + if (optionalFallback && optionalFallbackKey && isRemoteActionNotCallableError(error)) { + this.unsupportedOptionalActionKeys.add(optionalFallbackKey); + return optionalFallback; + } + throw error; + } if (value && typeof value === "object" && !Array.isArray(value)) { const record = value as Record; @@ -555,10 +620,15 @@ export class RemoteConnectionPool { !Array.isArray(record.error) ? (record.error as Record) : {}; + const message = typeof error.message === "string" + ? error.message + : "Remote ADE service action failed."; + if (optionalFallback && optionalFallbackKey && isRemoteActionNotCallableMessage(message)) { + this.unsupportedOptionalActionKeys.add(optionalFallbackKey); + return optionalFallback; + } throw new Error( - typeof error.message === "string" - ? error.message - : "Remote ADE service action failed.", + message, ); } return { @@ -769,6 +839,14 @@ export class RemoteConnectionPool { } } + private clearUnsupportedOptionalActionsForTarget(targetId: string): void { + for (const key of [...this.unsupportedOptionalActionKeys]) { + if (key.startsWith(`${targetId}\0`)) { + this.unsupportedOptionalActionKeys.delete(key); + } + } + } + private async requireEntry(targetId: string): Promise { const entry = this.entries.get(targetId); if (!entry) throw new Error(`Remote target is not connected: ${targetId}`); diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index e3a654f94..e63e779c3 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -333,6 +333,7 @@ describe("TopBar", () => { expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app")).toBeTruthy(); expect(screen.getByText("Remote App")).toBeTruthy(); expect(screen.getByLabelText("Remote: Mac Studio")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Remote, connected" })).toBeTruthy(); expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); expect(screen.queryByTitle("Connect a phone to this machine")).toBeNull(); }); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index de1b554bf..10bc208e2 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1004,7 +1004,8 @@ export function TopBar() { hasGitHubRemote === false && hasOrigin === false; const connectedRemoteCount = remoteSnapshot?.connectedCount ?? 0; - const remoteConnected = connectedRemoteCount > 0; + const remoteStatusCount = Math.max(connectedRemoteCount, remoteBinding ? 1 : 0); + const remoteConnected = remoteStatusCount > 0; const syncConnected = isSyncConnected(syncSnapshot); const showSyncControl = workspaceProjectOpen; @@ -2150,14 +2151,14 @@ export function TopBar() { onClick={handleOpenNew} disabled={isProjectBusy} title={ - connectedRemoteCount > 0 - ? `${connectedRemoteCount} remote device${connectedRemoteCount === 1 ? "" : "s"} available` + remoteStatusCount > 0 + ? `${remoteStatusCount} remote device${remoteStatusCount === 1 ? "" : "s"} available` : "Open another project" } style={ { WebkitAppRegion: "no-drag", - ...(connectedRemoteCount > 0 + ...(remoteStatusCount > 0 ? { color: "#FBBF24", borderColor: "rgba(245,158,11,0.58)", @@ -2375,7 +2376,7 @@ export function TopBar() { Remote machines
- {connectedRemoteCount} connected + {remoteStatusCount} connected
From d9bf7b5d53941722d55a522a2d0cadee2b13ce82 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:29:47 -0400 Subject: [PATCH 39/80] fix(remote): pause shell polling off work routes --- .../components/app/AppShell.aiStatus.test.tsx | 45 +++++++++++++++++++ .../src/renderer/components/app/AppShell.tsx | 29 +++++++++--- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx index 20479a831..9d7afa444 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx @@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope, AiSettingsStatus } from "../../../shared/types"; import { getAiStatusCached, invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; +import { listSessionsCached } from "../../lib/sessionListCache"; import { useAppStore } from "../../state/appStore"; import { AppShell } from "./AppShell"; @@ -92,6 +93,7 @@ describe("AppShell AI provider status", () => { invalidateAiDiscoveryCache(); getStatusMock.mockReset(); githubGetStatusMock.mockReset(); + vi.mocked(listSessionsCached).mockClear(); githubGetStatusMock.mockResolvedValue(null); chatEventListener = null; Object.defineProperty(window, "ade", { @@ -295,6 +297,49 @@ describe("AppShell AI provider status", () => { expect(githubGetStatusMock).toHaveBeenCalledTimes(1); }); + it("skips remote shell AI and stale-session polling on Files routes", async () => { + const remoteRoot = "/Users/admin/Projects/perf pass"; + const remoteProject = { rootPath: remoteRoot, displayName: "perf pass", baseRef: "main" } as any; + const remoteBinding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + runtimeName: "Mac Studio", + displayName: "perf pass", + rootPath: remoteRoot, + } as any; + const refreshProviderMode = vi.fn(async () => undefined); + (window.ade.app.getWindowSession as any).mockResolvedValue({ + project: remoteProject, + binding: remoteBinding, + }); + useAppStore.setState({ + project: remoteProject, + projectBinding: remoteBinding, + projectHydrated: true, + showWelcome: false, + refreshProviderMode, + } as any); + + render( + + +
Files content
+
+
, + ); + + await act(async () => { + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(getStatusMock).not.toHaveBeenCalled(); + expect(listSessionsCached).not.toHaveBeenCalled(); + }); + it("ignores stale auth-related chat history while remote AI status is cached", async () => { vi.setSystemTime(new Date("2026-06-04T12:00:00.000Z")); getStatusMock.mockResolvedValue(makeAiStatus(true)); diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 0d273c5e5..a5e578a8c 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -75,6 +75,16 @@ function shouldLoadShellGithubStatus(pathname: string, isRemoteProject: boolean) || pathname.startsWith("/settings/"); } +function shouldLoadShellAiStatus(pathname: string, isRemoteProject: boolean): boolean { + if (!isRemoteProject) return true; + return pathname === "/work" + || pathname.startsWith("/work/") + || pathname === "/lanes" + || pathname.startsWith("/lanes/") + || pathname === "/settings" + || pathname.startsWith("/settings/"); +} + const PROJECT_ROUTE_STORAGE_PREFIX = "ade:project-route:"; const AI_STATUS_STARTUP_DELAY_MS = 1_000; const AI_STATUS_CHAT_EVENT_REFRESH_MIN_GAP_MS = 30_000; @@ -320,11 +330,13 @@ export function AppShell({ children }: { children: React.ReactNode }) { githubBannerDismissedRef.current = githubBannerDismissed; const isOnboardingRoute = location.pathname === "/onboarding"; const isLanesRoute = location.pathname.startsWith("/lanes"); + const isWorkRoute = location.pathname === "/work" || location.pathname.startsWith("/work/"); + const isWorkAdjacentRoute = isWorkRoute || isLanesRoute; const isLanesRouteRef = useRef(isLanesRoute); const shouldTrackTerminalAttention = Boolean(currentProjectRoot) && !showWelcome && - (location.pathname === "/work" || location.pathname === "/lanes"); + isWorkAdjacentRoute; useEffect(() => { isLanesRouteRef.current = isLanesRoute; @@ -615,13 +627,18 @@ export function AppShell({ children }: { children: React.ReactNode }) { }, [setTerminalAttention, shouldTrackTerminalAttention]); useEffect(() => { - if (!project?.rootPath || showWelcome) { + const projectRoot = project?.rootPath ?? null; + const shouldCheckStaleCliNotice = + Boolean(projectRoot) && + !showWelcome && + (!isRemoteProject || isWorkAdjacentRoute); + if (!shouldCheckStaleCliNotice) { setStaleCliNotice(null); dismissedStaleCliNoticeKeyRef.current = null; return; } - const projectRoot = project.rootPath; + if (!projectRoot) return; let cancelled = false; let refreshTimer: number | null = null; @@ -683,7 +700,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, [project?.rootPath, showWelcome]); + }, [isRemoteProject, isWorkAdjacentRoute, project?.rootPath, showWelcome]); useEffect(() => { let cancelled = false; @@ -766,7 +783,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { useEffect(() => { let cancelled = false; - if (!currentProjectRoot) { + if (!currentProjectRoot || !shouldLoadShellAiStatus(location.pathname, isRemoteProject)) { setAiStatus(null); setAiStatusLoaded(false); return; @@ -836,7 +853,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { window.removeEventListener(AI_STATUS_CACHE_INVALIDATED_EVENT, onAiStatusCacheInvalidated); unsubscribeChatEvents(); }; - }, [currentProjectRoot, isRemoteProject]); + }, [currentProjectRoot, isRemoteProject, location.pathname]); useEffect(() => { let cancelled = false; From 969fac731f49238c0b90d00d1188cee92c6b800c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:37:21 -0400 Subject: [PATCH 40/80] fix(work): hide browser view off work routes --- .../src/renderer/components/app/App.tsx | 20 ++++++++++ .../components/app/App.workKeepAlive.test.tsx | 40 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index ad0ac5462..3f7148be0 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -333,6 +333,21 @@ function browserEventMatchesProject(event: unknown, projectRoot: string | null): return root === projectRoot; } +function hideBuiltInBrowserView(projectRoot: string | null): void { + const browser = window.ade?.builtInBrowser; + if (!browser) return; + const scope = projectRoot ? { projectRoot } : {}; + void browser.stopInspect(scope).catch(() => {}); + void browser.setBounds({ + ...scope, + x: 0, + y: 0, + width: 0, + height: 0, + visible: false, + }).catch(() => {}); +} + function projectNameFromRoot(rootPath: string | null | undefined): string | null { if (!rootPath) return null; const segments = rootPath.split(/[\\/]/).filter(Boolean); @@ -386,6 +401,11 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string setLanesRoute(route); }, [isLanesRoute, route]); + React.useEffect(() => { + if (active && isWorkRoute) return; + hideBuiltInBrowserView(projectRoot); + }, [active, isWorkRoute, projectRoot]); + React.useEffect(() => { if (!active || !projectRoot) return; const revealWorkBrowser = () => { diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index 712445b95..737abd39a 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -83,6 +83,7 @@ vi.mock("../../state/appStore", async () => { store.setState(partial); }), AppStoreProvider: ({ children }: { children: React.ReactNode }) => ReactModule.createElement(ReactModule.Fragment, null, children), + selectActiveProjectRoot: (state: typeof appStoreState) => state.project?.rootPath ?? null, }; }); @@ -209,6 +210,17 @@ describe("App Work route keep-alive", () => { appStoreState.projectInfoByRoot = {}; window.localStorage.clear(); (window as Window & { __adeBrowserMock?: boolean }).__adeBrowserMock = true; + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + builtInBrowser: { + stopInspect: vi.fn().mockResolvedValue({}), + setBounds: vi.fn().mockResolvedValue({}), + onEvent: vi.fn(() => () => {}), + }, + }, + }); window.history.replaceState({}, "", "/work"); }); @@ -247,6 +259,34 @@ describe("App Work route keep-alive", () => { expect(workLifecycle.unmounts).toBe(0); }); + it("parks the native Work browser view when the Work route is backgrounded", async () => { + const { App } = await import("./App"); + + render(); + + await screen.findByTestId("work-page"); + const browser = window.ade.builtInBrowser as unknown as { + stopInspect: ReturnType; + setBounds: ReturnType; + }; + expect(browser.setBounds).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Open files" })); + await screen.findByTestId("files-page"); + + await waitFor(() => { + expect(browser.stopInspect).toHaveBeenCalledWith({ projectRoot: "/fake/project" }); + expect(browser.setBounds).toHaveBeenCalledWith({ + projectRoot: "/fake/project", + x: 0, + y: 0, + width: 0, + height: 0, + visible: false, + }); + }); + }); + it("reveals the Work browser pane when an ADE browser URL opens from another tab", async () => { window.history.replaceState({}, "", "/files"); const { App } = await import("./App"); From ef873937aabff16036ce72b1b22da07613c5b958 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:46:13 -0400 Subject: [PATCH 41/80] fix(browser): detach hidden browser views --- .../builtInBrowserService.test.ts | 6 +++- .../builtInBrowser/builtInBrowserService.ts | 33 ++++++++++++------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts index 03bdd0784..49a6f767e 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts @@ -475,19 +475,23 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { it("keeps owned tabs alive while hidden and mutes them until visible", async () => { const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); - service.attachToWindow(fakeBrowserWindow() as unknown as Parameters[0]); + const win = fakeBrowserWindow(); + service.attachToWindow(win as unknown as Parameters[0]); await service.createTab({ url: "https://example.test", activate: true }); expect(service.getStatus().tabs).toHaveLength(1); + expect(win.contentView.children).toHaveLength(0); const wc = fakes.webContentsInstances[0]; expect(wc?.audioMutedCalls.at(-1)).toBe(true); await service.setBounds({ x: 12, y: 24, width: 640, height: 360, visible: true }); expect(service.getStatus().tabs).toHaveLength(1); + expect(win.contentView.children).toHaveLength(1); expect(wc?.audioMutedCalls.at(-1)).toBe(false); await service.setBounds({ x: 12, y: 24, width: 640, height: 360, visible: false }); expect(service.getStatus().tabs).toHaveLength(1); + expect(win.contentView.children).toHaveLength(0); expect(wc?.audioMutedCalls.at(-1)).toBe(true); }); diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts index b84a30b18..f1e4fa3a6 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts @@ -626,15 +626,19 @@ function createBuiltInBrowserWindowService(args: { } }; - const removeTabViewsFromWindow = (): void => { + const removeTabViewFromWindow = (tab: BrowserTabState): void => { if (!win || win.isDestroyed()) return; + if (!tab.view) return; + try { + win.contentView.removeChildView(tab.view); + } catch { + // ignore stale view/window links + } + }; + + const removeTabViewsFromWindow = (): void => { for (const tab of tabs) { - if (!tab.view) continue; - try { - win.contentView.removeChildView(tab.view); - } catch { - // ignore stale view/window links - } + removeTabViewFromWindow(tab); } }; @@ -1202,13 +1206,20 @@ function createBuiltInBrowserWindowService(args: { applyTabLifecycle(tab, visible && tab.id === activeTabId); continue; } + const isActive = tab.id === activeTabId; + const shouldAttach = visible && isActive; + if (!shouldAttach) { + tab.view.setVisible(false); + removeTabViewFromWindow(tab); + applyTabLifecycle(tab, false); + continue; + } if (!win.contentView.children.includes(tab.view)) { win.contentView.addChildView(tab.view); } - const isActive = tab.id === activeTabId; - if (isActive) tab.view.setBounds(electronRect); - tab.view.setVisible(visible && isActive); - applyTabLifecycle(tab, visible && isActive); + tab.view.setBounds(electronRect); + tab.view.setVisible(true); + applyTabLifecycle(tab, true); } }; From 3c38239a135eb3d28d5338a6248cffdffaafbada Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:52:14 -0400 Subject: [PATCH 42/80] fix(lanes): dedupe mirrored agent sessions --- .../components/lanes/laneAgents.test.ts | 31 +++++++++++++++++++ .../renderer/components/lanes/laneAgents.ts | 7 +++++ 2 files changed, 38 insertions(+) diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts index f77163b37..de62defa4 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts @@ -62,6 +62,37 @@ describe("buildLaneAgents", () => { expect(agents).toHaveLength(0); }); + it("prefers chat summaries when the same chat session is mirrored through sessions.list", () => { + const agents = buildLaneAgents( + [chat({ sessionId: "same-session", title: "Chat row", provider: "codex" })], + [cli({ id: "same-session", toolType: "codex-chat", title: "Terminal mirror" })], + ); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + sessionId: "same-session", + kind: "chat", + name: "Chat row", + }); + }); + + it("collapses duplicate terminal summaries for the same session", () => { + const agents = buildLaneAgents( + [], + [ + cli({ id: "duplicated", title: "First terminal row" }), + cli({ id: "duplicated", title: "Second terminal row" }), + ], + ); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + sessionId: "duplicated", + kind: "cli", + name: "First terminal row", + }); + }); + it("merges chat + CLI agents and sorts live before ended", () => { const agents = buildLaneAgents( [ diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.ts index 54a34cdfb..107570236 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.ts @@ -103,14 +103,21 @@ export function buildLaneAgents( cliSessions: TerminalSessionSummary[], ): LaneAgent[] { const agents: LaneAgent[] = []; + const chatSessionIds = new Set(); for (const summary of chatSessions) { if (summary.archivedAt) continue; + if (chatSessionIds.has(summary.sessionId)) continue; + chatSessionIds.add(summary.sessionId); agents.push(chatAgentFrom(summary)); } + const cliSessionIds = new Set(); for (const summary of cliSessions) { if (summary.archivedAt) continue; if (SHELL_TOOL_TYPES.has(summary.toolType ?? "shell")) continue; if (summary.chatSessionId) continue; // child terminal of a chat — not a standalone agent + if (chatSessionIds.has(summary.id)) continue; // persisted chat session mirrored through sessions.list + if (cliSessionIds.has(summary.id)) continue; + cliSessionIds.add(summary.id); agents.push(cliAgentFrom(summary)); } // Live agents first (working → awaiting → idle), ended last; within a bucket, From 1c0a6cd0969a00cd3cb57ad0c22ea0171ffd3365 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:01:35 -0400 Subject: [PATCH 43/80] fix(graph): refresh active remote graph when document hidden --- .../src/renderer/components/graph/WorkspaceGraphPage.tsx | 9 ++++----- apps/desktop/src/renderer/components/lanes/laneAgents.ts | 1 - 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index 84155ab90..cb92f10e9 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -818,7 +818,6 @@ function GraphInner({ active = true }: { active?: boolean }) { if (activityRefreshTimerRef.current != null) return; activityRefreshTimerRef.current = window.setTimeout(() => { activityRefreshTimerRef.current = null; - if (document.visibilityState !== "visible") return; if (activityRefreshInFlightRef.current) { activityRefreshQueuedRef.current = true; return; @@ -873,19 +872,19 @@ function GraphInner({ active = true }: { active?: boolean }) { } riskTimer = window.setTimeout(() => { - if (cancelled || document.visibilityState !== "visible") return; + if (cancelled) return; void refreshRiskBatch(); }, 1_500); activityTimer = window.setTimeout(() => { - if (cancelled || document.visibilityState !== "visible") return; + if (cancelled) return; scheduleRefreshActivity(250, { includeOperations: true }); }, 800); syncTimer = window.setTimeout(() => { - if (cancelled || document.visibilityState !== "visible") return; + if (cancelled) return; void refreshLaneSyncStatuses(); }, 2_500); autoRebaseTimer = window.setTimeout(() => { - if (cancelled || document.visibilityState !== "visible") return; + if (cancelled) return; void refreshAutoRebaseStatuses(); }, 3_500); diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.ts index 107570236..03ffce8f2 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.ts @@ -156,7 +156,6 @@ export function useLaneAgents(laneIds: string[]): Map { try { do { refreshQueuedRef.current = false; - if (document.visibilityState !== "visible") return; const ids = laneKey ? laneKey.split(",") : []; if (!ids.length) { From 24a0d37f998243058e4b1134c58503ab23c1de5d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:11:35 -0400 Subject: [PATCH 44/80] fix(work): batch remote terminal input --- .../terminals/TerminalView.test.tsx | 127 ++++++++++++++++++ .../components/terminals/TerminalView.tsx | 78 ++++++++--- 2 files changed, 187 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index 9d66b2930..b5d79153c 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -747,6 +747,133 @@ describe("TerminalView", () => { expect(terminal?.options.scrollback).toBe(20_000); }); + it("batches rapid terminal input before writing to the PTY", async () => { + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + onData: ReturnType; + } | undefined; + const onData = terminal?.onData.mock.calls.at(-1)?.[0] as ((data: string) => void) | undefined; + expect(onData).toBeTruthy(); + + const ptyWrite = window.ade.pty.write as unknown as ReturnType; + ptyWrite.mockClear(); + + onData!("p"); + await act(async () => { + await vi.advanceTimersByTimeAsync(8); + }); + onData!("w"); + await act(async () => { + await vi.advanceTimersByTimeAsync(8); + }); + onData!("d"); + + expect(ptyWrite).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15); + }); + expect(ptyWrite).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(ptyWrite).toHaveBeenCalledTimes(1); + expect(ptyWrite).toHaveBeenCalledWith({ + ptyId: "pty-input-batch", + data: "pwd", + }); + }); + + it("flushes queued terminal input immediately when Enter arrives", async () => { + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + onData: ReturnType; + } | undefined; + const onData = terminal?.onData.mock.calls.at(-1)?.[0] as ((data: string) => void) | undefined; + expect(onData).toBeTruthy(); + + const ptyWrite = window.ade.pty.write as unknown as ReturnType; + ptyWrite.mockClear(); + + onData!("pwd"); + await act(async () => { + await vi.advanceTimersByTimeAsync(8); + }); + expect(ptyWrite).not.toHaveBeenCalled(); + + onData!("\r"); + + expect(ptyWrite).toHaveBeenCalledTimes(1); + expect(ptyWrite).toHaveBeenCalledWith({ + ptyId: "pty-input-enter", + data: "pwd\r", + }); + }); + + it("preserves queued input order before direct control-key writes", async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "platform"); + const originalPlatform = window.navigator.platform; + try { + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: "MacIntel", + }); + + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + attachCustomKeyEventHandler: ReturnType; + onData: ReturnType; + } | undefined; + const onData = terminal?.onData.mock.calls.at(-1)?.[0] as ((data: string) => void) | undefined; + const keyHandler = terminal?.attachCustomKeyEventHandler.mock.calls.at(-1)?.[0] as ((ev: KeyboardEvent) => boolean) | undefined; + expect(onData).toBeTruthy(); + expect(keyHandler).toBeTruthy(); + + const ptyWrite = window.ade.pty.write as unknown as ReturnType; + ptyWrite.mockClear(); + + onData!("p"); + const preventDefault = vi.fn(); + const handled = keyHandler!({ + type: "keydown", + key: "c", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + preventDefault, + } as unknown as KeyboardEvent); + + expect(handled).toBe(false); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(ptyWrite).toHaveBeenNthCalledWith(1, { + ptyId: "pty-input-control", + data: "p", + }); + expect(ptyWrite).toHaveBeenNthCalledWith(2, { + ptyId: "pty-input-control", + data: "\x03", + }); + } finally { + if (platformDescriptor) { + Object.defineProperty(window.navigator, "platform", platformDescriptor); + } else { + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: originalPlatform, + }); + } + } + }); + it("writes text paste contents directly to the PTY", async () => { render(); await flushAllTimers(); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index dae81352a..984aa835d 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -84,6 +84,9 @@ type CachedRuntime = { pendingHydrationBytes: number; frameWriteChunks: string[]; frameWriteBytes: number; + inputWriteChunks: string[]; + inputWriteBytes: number; + inputFlushTimer: ReturnType | null; liveStreamPaused: boolean; flushRafId: number | null; flushTimer: ReturnType | null; @@ -122,6 +125,8 @@ const HYDRATION_VISIBLE_BLANK_BACKFILL_RETRY_MS = 100; const HYDRATION_BACKFILL_MAX_ATTEMPTS = 120; const MAX_PENDING_HYDRATION_BYTES = 2_000_000; const MAX_FRAME_WRITE_BYTES = 1_000_000; +const MAX_PTY_INPUT_BATCH_BYTES = 16_384; +const PTY_INPUT_BATCH_MS = 16; const EXITED_RUNTIME_KEEPALIVE_MS = 8_000; const MIN_VALID_COLS = 20; const MIN_VALID_ROWS = 6; @@ -753,11 +758,56 @@ function setRuntimeVisibilityState(runtime: CachedRuntime, visible: boolean) { syncRuntimePtyDataStreaming(runtime, wasReceiving); } -function writePtyInput(runtime: CachedRuntime, data: string) { +function clearPtyInputFlushTimer(runtime: CachedRuntime): void { + if (runtime.inputFlushTimer) { + clearTimeout(runtime.inputFlushTimer); + runtime.inputFlushTimer = null; + } +} + +function writePtyInputNow(runtime: CachedRuntime, data: string) { if (!data || runtime.disposed) return; window.ade.pty.write({ ptyId: runtime.ptyId, data }).catch(() => {}); } +function flushPendingPtyInput(runtime: CachedRuntime): void { + clearPtyInputFlushTimer(runtime); + if (!runtime.inputWriteChunks.length || runtime.disposed) return; + const data = runtime.inputWriteChunks.join(""); + runtime.inputWriteChunks.length = 0; + runtime.inputWriteBytes = 0; + writePtyInputNow(runtime, data); +} + +function writePtyInput(runtime: CachedRuntime, data: string) { + if (!data || runtime.disposed) return; + flushPendingPtyInput(runtime); + writePtyInputNow(runtime, data); +} + +function shouldFlushPtyInputImmediately(data: string): boolean { + return /[\x00-\x1f\x7f]|\x1b/.test(data); +} + +function schedulePtyInputFlush(runtime: CachedRuntime): void { + clearPtyInputFlushTimer(runtime); + runtime.inputFlushTimer = setTimeout(() => { + runtime.inputFlushTimer = null; + flushPendingPtyInput(runtime); + }, PTY_INPUT_BATCH_MS); +} + +function enqueuePtyInput(runtime: CachedRuntime, data: string) { + if (!data || runtime.disposed) return; + runtime.inputWriteChunks.push(data); + runtime.inputWriteBytes += data.length; + if (shouldFlushPtyInputImmediately(data) || runtime.inputWriteBytes >= MAX_PTY_INPUT_BATCH_BYTES) { + flushPendingPtyInput(runtime); + return; + } + schedulePtyInputFlush(runtime); +} + function updateTerminalMouseTrackingModes(runtime: CachedRuntime, data: string): void { for (const match of data.matchAll(/\x1b\[\?([0-9;]+)([hl])/g)) { const action = match[2]; @@ -868,11 +918,13 @@ async function pasteNativeClipboardImageShortcut(runtime: CachedRuntime): Promis } function teardownRuntime(runtime: CachedRuntime) { + flushPendingPtyInput(runtime); runtime.disposed = true; clearDisposeTimer(runtime); if (runtime.fitRafId != null) cancelAnimationFrame(runtime.fitRafId); if (runtime.flushRafId != null) cancelAnimationFrame(runtime.flushRafId); if (runtime.flushTimer) clearTimeout(runtime.flushTimer); + clearPtyInputFlushTimer(runtime); if (runtime.settleTimer1) clearTimeout(runtime.settleTimer1); if (runtime.settleTimer2) clearTimeout(runtime.settleTimer2); if (runtime.hydrateTimer) clearTimeout(runtime.hydrateTimer); @@ -1717,6 +1769,9 @@ function createRuntime(args: { pendingHydrationBytes: 0, frameWriteChunks: [], frameWriteBytes: 0, + inputWriteChunks: [], + inputWriteBytes: 0, + inputFlushTimer: null, liveStreamPaused: false, flushRafId: null, flushTimer: null, @@ -1835,24 +1890,11 @@ function createRuntime(args: { return true; }); - // Batch rapid onData events (e.g. paste via context-menu) into single PTY writes - let inputBuf: string[] = []; - let inputFlushQueued = false; - + // Debounce rapid terminal input into fewer PTY writes. Remote runtimes pay a + // full RPC round trip per write, so per-character bursts make typing feel + // much slower than a local shell. runtime.termDataSub = term.onData((data) => { - if (runtime.disposed) return; - inputBuf.push(data); - if (!inputFlushQueued) { - inputFlushQueued = true; - queueMicrotask(() => { - inputFlushQueued = false; - const merged = inputBuf.join(""); - inputBuf = []; - if (merged && !runtime.disposed) { - writePtyInput(runtime, merged); - } - }); - } + enqueuePtyInput(runtime, data); }); runtime.ptyDataUnsub = subscribeRuntimePtyData(runtime); From ea77f9697c4159ff9268ca15a2e09fadc19eac88 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:13:38 -0400 Subject: [PATCH 45/80] fix(work): preserve batched terminal input ordering --- .../components/terminals/TerminalView.test.tsx | 11 ++++------- .../renderer/components/terminals/TerminalView.tsx | 13 +++++++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index b5d79153c..5b1631350 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -816,7 +816,7 @@ describe("TerminalView", () => { }); }); - it("preserves queued input order before direct control-key writes", async () => { + it("preserves queued input order inside direct control-key writes", async () => { const platformDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "platform"); const originalPlatform = window.navigator.platform; try { @@ -854,13 +854,10 @@ describe("TerminalView", () => { expect(handled).toBe(false); expect(preventDefault).toHaveBeenCalledTimes(1); - expect(ptyWrite).toHaveBeenNthCalledWith(1, { - ptyId: "pty-input-control", - data: "p", - }); - expect(ptyWrite).toHaveBeenNthCalledWith(2, { + expect(ptyWrite).toHaveBeenCalledTimes(1); + expect(ptyWrite).toHaveBeenCalledWith({ ptyId: "pty-input-control", - data: "\x03", + data: "p\x03", }); } finally { if (platformDescriptor) { diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index 984aa835d..89d060770 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -770,19 +770,24 @@ function writePtyInputNow(runtime: CachedRuntime, data: string) { window.ade.pty.write({ ptyId: runtime.ptyId, data }).catch(() => {}); } -function flushPendingPtyInput(runtime: CachedRuntime): void { +function consumePendingPtyInput(runtime: CachedRuntime): string { clearPtyInputFlushTimer(runtime); - if (!runtime.inputWriteChunks.length || runtime.disposed) return; + if (!runtime.inputWriteChunks.length || runtime.disposed) return ""; const data = runtime.inputWriteChunks.join(""); runtime.inputWriteChunks.length = 0; runtime.inputWriteBytes = 0; + return data; +} + +function flushPendingPtyInput(runtime: CachedRuntime): void { + const data = consumePendingPtyInput(runtime); + if (!data) return; writePtyInputNow(runtime, data); } function writePtyInput(runtime: CachedRuntime, data: string) { if (!data || runtime.disposed) return; - flushPendingPtyInput(runtime); - writePtyInputNow(runtime, data); + writePtyInputNow(runtime, `${consumePendingPtyInput(runtime)}${data}`); } function shouldFlushPtyInputImmediately(data: string): boolean { From 31a10e6ddb3caf804a3c983dd6e8d43fd7a8bc07 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:21:09 -0400 Subject: [PATCH 46/80] fix(remote): tolerate missing PR queue states --- .../remoteConnectionPool.test.ts | 51 +++++++++++++++++++ .../remoteRuntime/remoteConnectionPool.ts | 50 ++++++++++-------- 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts index a1e890752..be67f2155 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts @@ -810,6 +810,57 @@ describe("RemoteConnectionPool", () => { }, { timeoutMs: 25_000 }); }); + it("falls back to empty PR queue states when an older runtime lacks that optional action", async () => { + const client = createClient(); + client.call.mockResolvedValueOnce({ + ok: false, + error: { message: "Action 'pr.listQueueStates' is not callable." }, + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client, + ssh: createSsh(), + result: connectResult("0.9.0"), + }); + const pool = new RemoteConnectionPool({ get: () => null } as unknown as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "pr", + action: "listQueueStates", + args: { includeCompleted: true, limit: 50 }, + }), + ).resolves.toEqual({ + domain: "pr", + action: "listQueueStates", + result: [], + statusHints: { optionalActionMissing: true }, + }); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "pr", + action: "listQueueStates", + args: { includeCompleted: true, limit: 50 }, + }), + ).resolves.toEqual({ + domain: "pr", + action: "listQueueStates", + result: [], + statusHints: { optionalActionMissing: true }, + }); + + expect(client.call).toHaveBeenCalledTimes(1); + expect(client.call).toHaveBeenCalledWith("ade/actions/call", { + projectId: "project-1", + name: "run_ade_action", + arguments: { + domain: "pr", + action: "listQueueStates", + args: { includeCompleted: true, limit: 50 }, + }, + }, { timeoutMs: 25_000 }); + }); + it("lists remote ADE actions as grouped registry entries", async () => { const client = createClient(); client.call.mockResolvedValueOnce({ diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts index 534f1f2b9..1dd22f1b3 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts @@ -207,27 +207,37 @@ function assertMachineProjectCapability(entry: PoolEntry, method: string): void function optionalRemoteActionFallbackResult( request: RemoteRuntimeActionRequest, ): RemoteRuntimeActionResult | null { - if (request.domain !== "file" || request.action !== "refreshGitDecorations") { - return null; + if (request.domain === "file" && request.action === "refreshGitDecorations") { + const args = request.args && typeof request.args === "object" && !Array.isArray(request.args) + ? request.args as Record + : {}; + const workspaceId = typeof args.workspaceId === "string" && args.workspaceId.trim() + ? args.workspaceId.trim() + : "primary"; + return { + domain: "file", + action: "refreshGitDecorations", + result: { + workspaceId, + files: [], + directories: [], + }, + statusHints: { + optionalActionMissing: true, + }, + }; } - const args = request.args && typeof request.args === "object" && !Array.isArray(request.args) - ? request.args as Record - : {}; - const workspaceId = typeof args.workspaceId === "string" && args.workspaceId.trim() - ? args.workspaceId.trim() - : "primary"; - return { - domain: "file", - action: "refreshGitDecorations", - result: { - workspaceId, - files: [], - directories: [], - }, - statusHints: { - optionalActionMissing: true, - }, - }; + if (request.domain === "pr" && request.action === "listQueueStates") { + return { + domain: "pr", + action: "listQueueStates", + result: [], + statusHints: { + optionalActionMissing: true, + }, + }; + } + return null; } function isRemoteActionNotCallableMessage(message: string): boolean { From 6dae4a90df356583d6fc3fb59a2d1aa56f84935a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:55:09 -0400 Subject: [PATCH 47/80] fix(remote): smooth files recents and lane git menus --- .../components/files/v2/FilesWorkbench.tsx | 19 ++++++++++++++-- .../components/files/v2/recentFiles.test.ts | 15 ++++++++++++- .../components/files/v2/recentFiles.ts | 7 ++++++ .../lanes/LaneGitActionsPane.test.tsx | 22 +++++++++++++++++++ .../components/lanes/LaneGitActionsPane.tsx | 21 +++++++++++++++++- 5 files changed, 80 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx index 37fee3d65..68d0dba9d 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx @@ -32,7 +32,7 @@ import { } from "./editorGroupsStore"; import { resolveViewerKind } from "./viewerRegistry"; import { invalidateFileContent, primeFileContent } from "./useFileContent"; -import { forgetRecentFile, getRecentFiles, recordRecentFile } from "./recentFiles"; +import { forgetRecentFile, getRecentFiles, pruneMissingRootRecentFiles, recordRecentFile } from "./recentFiles"; import { EditorGroups } from "./EditorGroups"; import { StatusBar } from "./StatusBar"; import { WarmEmptyState } from "./WarmEmptyState"; @@ -125,6 +125,21 @@ export function FilesWorkbench({ () => new Set(Object.values(groupsState.groups).flatMap((g) => g.tabs.map((t) => t.path))).size, [groupsState.groups], ); + const knownRootPaths = useMemo(() => new Set(tree.map((node) => node.path)), [tree]); + const recentFiles = getRecentFiles(sessionKey); + const visibleRecentFiles = useMemo( + () => ( + tree.length > 0 + ? recentFiles.filter((path) => path.includes("/") || knownRootPaths.has(path)) + : recentFiles + ), + [knownRootPaths, recentFiles, tree.length], + ); + + useEffect(() => { + if (tree.length === 0) return; + pruneMissingRootRecentFiles(sessionKey, knownRootPaths); + }, [knownRootPaths, sessionKey, tree.length]); /* ---- Workspace resolution ---- */ useEffect(() => { @@ -607,7 +622,7 @@ export function FilesWorkbench({ workspaceName={workspace?.name ?? null} branch={branch} dirtyCount={dirtyPaths.size} - recents={getRecentFiles(sessionKey)} + recents={visibleRecentFiles} onOpen={(path) => void openFile(path, { preview: false })} onSearch={() => setOverlay({ kind: "search", query: "" })} modifierKey={modifierKeyLabel} diff --git a/apps/desktop/src/renderer/components/files/v2/recentFiles.test.ts b/apps/desktop/src/renderer/components/files/v2/recentFiles.test.ts index dd6ef3479..69432f0e8 100644 --- a/apps/desktop/src/renderer/components/files/v2/recentFiles.test.ts +++ b/apps/desktop/src/renderer/components/files/v2/recentFiles.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { forgetRecentFile, getRecentFiles, recordRecentFile } from "./recentFiles"; +import { forgetRecentFile, getRecentFiles, pruneMissingRootRecentFiles, recordRecentFile } from "./recentFiles"; describe("recentFiles", () => { it("removes stale paths without disturbing other recents", () => { @@ -24,4 +24,17 @@ describe("recentFiles", () => { expect(getRecentFiles(sessionKey)).toEqual(["README.md", "src/index.ts"]); }); + + it("prunes root-level recents missing from the loaded root tree", () => { + const sessionKey = "recent-files-test-prune-root"; + + recordRecentFile(sessionKey, "README.md"); + recordRecentFile(sessionKey, "deleted.txt"); + recordRecentFile(sessionKey, "src/index.ts"); + + const visible = pruneMissingRootRecentFiles(sessionKey, new Set(["README.md"])); + + expect(visible).toEqual(["src/index.ts", "README.md"]); + expect(getRecentFiles(sessionKey)).toEqual(["src/index.ts", "README.md"]); + }); }); diff --git a/apps/desktop/src/renderer/components/files/v2/recentFiles.ts b/apps/desktop/src/renderer/components/files/v2/recentFiles.ts index d4f9f7ab3..fa9162a3a 100644 --- a/apps/desktop/src/renderer/components/files/v2/recentFiles.ts +++ b/apps/desktop/src/renderer/components/files/v2/recentFiles.ts @@ -18,3 +18,10 @@ export function forgetRecentFile(sessionKey: string, path: string): void { if (!current) return; recentsBySession.set(sessionKey, current.filter((p) => p !== path)); } + +export function pruneMissingRootRecentFiles(sessionKey: string, knownRootPaths: ReadonlySet): string[] { + const current = recentsBySession.get(sessionKey) ?? []; + const next = current.filter((path) => path.includes("/") || knownRootPaths.has(path)); + if (next.length !== current.length) recentsBySession.set(sessionKey, next); + return next; +} diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx index ca5272698..a1744b992 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx @@ -656,6 +656,28 @@ describe("LaneGitActionsPane rescue action", () => { }); }); + it("closes advanced git actions on Escape and Refresh", async () => { + const user = userEvent.setup(); + + renderPane(); + + await user.click(await screen.findByRole("button", { name: /more/i })); + expect(screen.getByRole("button", { name: /fetch only/i })).toBeTruthy(); + + await user.keyboard("{Escape}"); + await waitFor(() => { + expect(screen.queryByRole("button", { name: /fetch only/i })).toBeNull(); + }); + + await user.click(screen.getByRole("button", { name: /more/i })); + expect(screen.getByRole("button", { name: /fetch only/i })).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: /refresh git state/i })); + await waitFor(() => { + expect(screen.queryByRole("button", { name: /fetch only/i })).toBeNull(); + }); + }); + it("updates the stash section even if the broader refresh fails afterward", async () => { const user = userEvent.setup(); mockStashesByLaneId["lane-1"] = [ diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 56c6d58ca..77ff41733 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -799,6 +799,21 @@ export function LaneGitActionsPane({ } }; + useEffect(() => { + setShowAdvanced(false); + }, [laneId]); + + useEffect(() => { + if (!showAdvanced) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + setShowAdvanced(false); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [showAdvanced]); + const refreshAutoRebaseStatus = useCallback(async (targetLaneId: string | null = laneId) => { if (!targetLaneId) { if (isViewingLane(targetLaneId)) { @@ -2224,6 +2239,7 @@ export function LaneGitActionsPane({ }}> From 40dbc125828aa6c4344a71fc8e182a1eac0ca1e8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:20:33 -0400 Subject: [PATCH 48/80] fix(work): tolerate missing active terminal chat args --- apps/desktop/src/main/services/pty/ptyService.test.ts | 2 ++ apps/desktop/src/main/services/pty/ptyService.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 3d1bee6e2..68b941601 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -5068,6 +5068,8 @@ describe("ptyService", () => { /No running terminal/, ); expect(service.activeForChat({ chatSessionId: "no-such-chat" })).toBeNull(); + expect(service.activeForChat({})).toBeNull(); + expect(service.activeForChat(null)).toBeNull(); }); describe("reattachChatCli", () => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 6e969e7de..40b90f702 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -4278,8 +4278,8 @@ export function createPtyService({ }); }, - activeForChat(args: ChatTerminalActiveForChatArgs): ChatTerminalSession | null { - const chatSessionId = cleanOptionalId(args.chatSessionId); + activeForChat(args?: Partial | null): ChatTerminalSession | null { + const chatSessionId = cleanOptionalId(args?.chatSessionId); if (!chatSessionId) return null; const chatCli = activeChatCliEntryFor(chatSessionId); if (chatCli) { From 416c827b99a3092180ec5aca9ec63718088ec3d6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:27:07 -0400 Subject: [PATCH 49/80] fix(remote): tighten remote event and chat actions --- .../main/services/adeActions/registry.test.ts | 31 ++++++++ .../src/main/services/adeActions/registry.ts | 1 + .../main/services/ipc/runtimeBridge.test.ts | 33 +++++++++ .../src/main/services/ipc/runtimeBridge.ts | 6 +- apps/desktop/src/preload/preload.test.ts | 72 +++++++++++++++++++ apps/desktop/src/preload/preload.ts | 16 ++++- apps/desktop/src/renderer/main.tsx | 9 +++ 7 files changed, 165 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 433421810..652fb388d 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -72,6 +72,11 @@ describe("isAllowedAdeAction", () => { expect(isCtoOnlyAdeAction("ios_simulator", "ensurePreviewWorkspace")).toBe(false); }); + it("exposes subagent transcript reads through the chat runtime action surface", () => { + expect(isAllowedAdeAction("chat", "getSubagentTranscript")).toBe(true); + expect(isCtoOnlyAdeAction("chat", "getSubagentTranscript")).toBe(false); + }); + it("rejects an unknown action on a known domain", () => { expect(isAllowedAdeAction("git", "rmRf")).toBe(false); expect(isAllowedAdeAction("issue", "deleteAllIssues")).toBe(false); @@ -640,6 +645,32 @@ describe("runtime session actions", () => { }); }); + it("forwards remote subagent transcript reads through chat actions", async () => { + const transcript = [{ role: "assistant", content: "subagent output" }]; + const getSubagentTranscript = vi.fn(async () => transcript); + const runtime = { + agentChatService: { + getSubagentTranscript, + }, + } as unknown as Parameters[0]; + const chatService = getAdeActionDomainServices(runtime).chat as { + getSubagentTranscript: (args: { + sessionId: string; + subagentId: string; + }) => Promise>; + } & Record; + + expect(listAllowedAdeActionNames("chat", chatService)).toContain("getSubagentTranscript"); + await expect(chatService.getSubagentTranscript({ + sessionId: "chat-1", + subagentId: "subagent-1", + })).resolves.toEqual(transcript); + expect(getSubagentTranscript).toHaveBeenCalledWith({ + sessionId: "chat-1", + subagentId: "subagent-1", + }); + }); + it("adds getDelta from the runtime session delta service", () => { const delta = { sessionId: "session-1", filesChanged: 2 }; const runtime = { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index d2114158f..afffd587e 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -449,6 +449,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { ); }); + it("cleans a remote event subscription before disconnecting that target", async () => { + const cleanup = vi.fn(); + remoteRegistryGetMock.mockReturnValue(target); + remoteSubscribeEventsForTargetMock.mockResolvedValue(cleanup); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + }); + const webContents = sender(202); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeStreamEvents)?.( + eventForSender(webContents), + { + id: "target-1", + projectId: "project-1", + request: { cursor: 0, limit: 100, replay: false }, + }, + ), + ).resolves.toEqual({ events: [], nextCursor: 0, hasMore: false }); + await Promise.resolve(); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeDisconnect)?.( + eventForSender(webContents), + { id: "target-1" }, + ), + ).resolves.toEqual({ disconnected: true }); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(remoteDisconnectMock).toHaveBeenCalledWith("target-1"); + }); + it("rejects unexposed sync methods before calling local or remote runtimes", async () => { const localRuntimeConnectionPool = { callSyncForRoot: vi.fn(), diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 0779d1544..b146018af 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -1125,9 +1125,13 @@ export function registerRuntimeBridge({ ipcMain.handle( IPC.remoteRuntimeDisconnect, - async (_event, arg: { id: string }): Promise<{ disconnected: boolean }> => { + async (event, arg: { id: string }): Promise<{ disconnected: boolean }> => { const id = typeof arg?.id === "string" ? arg.id.trim() : ""; if (!id) return { disconnected: false }; + const currentSubscription = runtimeEventSubscriptions.get(event.sender.id); + if (currentSubscription?.bindingKey.startsWith(`remote:${id}:`)) { + cleanupRuntimeEventSubscription(event.sender.id); + } remoteConnectionService.disconnect(id); return { disconnected: true }; }, diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 18ff200db..7d852455b 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -4242,6 +4242,78 @@ describe("preload OAuth bridge", () => { } }); + it("stops remote event polling after disconnecting the active remote target", async () => { + vi.useFakeTimers(); + try { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const streamRequests: unknown[] = []; + const invoke = vi.fn(async (channel: string, arg?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeStreamEvents) { + streamRequests.push(arg); + return { + events: [], + nextCursor: 0, + hasMore: false, + eventEpoch: "epoch-a", + }; + } + if (channel === IPC.remoteRuntimeDisconnect) { + return { disconnected: true }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + const unsubscribe = bridge.project.onStateEvent(vi.fn()); + + await vi.advanceTimersByTimeAsync(0); + expect(streamRequests).toHaveLength(1); + + await expect(bridge.remoteRuntime.disconnect("target-1")).resolves.toEqual({ + disconnected: true, + }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeDisconnect, { + id: "target-1", + }); + + await vi.advanceTimersByTimeAsync(20_000); + expect(streamRequests).toHaveLength(1); + + unsubscribe(); + } finally { + vi.useRealTimers(); + } + }); + it("drops stale remote runtime catch-up when the project binding changes mid-poll", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index bf7683d22..669c26b34 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3490,8 +3490,20 @@ contextBridge.exposeInMainWorld("ade", { project: RemoteRuntimeProjectRecord, ): Promise => ipcRenderer.invoke(IPC.remoteRuntimeCheckLocalWork, { id, project }), - disconnect: async (id: string): Promise<{ disconnected: boolean }> => - ipcRenderer.invoke(IPC.remoteRuntimeDisconnect, { id }), + disconnect: async (id: string): Promise<{ disconnected: boolean }> => { + const trimmedId = typeof id === "string" ? id.trim() : ""; + const result = (await ipcRenderer.invoke(IPC.remoteRuntimeDisconnect, { + id: trimmedId, + })) as { disconnected: boolean }; + if ( + result.disconnected && + currentProjectBinding?.kind === "remote" && + currentProjectBinding.targetId === trimmedId + ) { + rememberProjectBinding(null); + } + return result; + }, }, keybindings: { get: async (): Promise => diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index 9cf5cb28e..a846ef377 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -61,7 +61,16 @@ function readRendererMemory() { }; } +function isBenignWindowError(message: string): boolean { + return message === "ResizeObserver loop completed with undelivered notifications." + || message === "ResizeObserver loop limit exceeded"; +} + window.addEventListener("error", (event) => { + if (isBenignWindowError(event.message)) { + event.preventDefault(); + return; + } logRendererDebugEvent("renderer.window_error", { message: event.message, filename: event.filename, From 5d442d5feea3e6f6c34b14e70a9f3f6d17c6266d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:36:59 -0400 Subject: [PATCH 50/80] fix(work): hide unsupported remote tool panes --- .../components/terminals/WorkSidebar.test.tsx | 41 +++++++++++-- .../components/terminals/WorkSidebar.tsx | 60 ++++++++++++------- 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx index 6854e51ab..9c914d78f 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx @@ -13,7 +13,7 @@ import type { TerminalSessionSummary, } from "../../../shared/types"; import { ADE_WORK_PTY_CONTEXT_INSERTED_EVENT } from "../../lib/workPtyContextEvents"; -import { useAppStore } from "../../state/appStore"; +import { useAppStore, type WorkSidebarTab } from "../../state/appStore"; import { WorkSidebar, type WorkSidebarContextTarget } from "./WorkSidebar"; vi.mock("../chat/ChatIosSimulatorPanel", async () => { @@ -311,12 +311,13 @@ function installAdeMock(options: { } function renderSidebar(args: { - tab: "ios" | "app-control" | "browser"; + tab: WorkSidebarTab; contextTarget: WorkSidebarContextTarget | null; contextDisabledReason?: string | null; laneId?: string; lanes?: LaneSummary[]; activeSession?: TerminalSessionSummary | null; + onTabChange?: (tab: WorkSidebarTab) => void; }) { return render( @@ -326,7 +327,7 @@ function renderSidebar(args: { lanes={args.lanes ?? [lane]} activeSession={args.activeSession ?? activeSession} tab={args.tab} - onTabChange={vi.fn()} + onTabChange={args.onTabChange ?? vi.fn()} onClose={vi.fn()} contextTarget={args.contextTarget} contextDisabledReason={args.contextDisabledReason ?? null} @@ -342,7 +343,7 @@ describe("WorkSidebar context targets", () => { afterEach(() => { cleanup(); - useAppStore.setState({ project: null } as any); + useAppStore.setState({ project: null, projectBinding: null } as any); delete (window as unknown as { ade?: unknown }).ade; vi.restoreAllMocks(); }); @@ -567,4 +568,36 @@ describe("WorkSidebar context targets", () => { }, })).not.toThrow(); }); + + it("only exposes remote-aware tool panes for remote projects", async () => { + const onTabChange = vi.fn(); + useAppStore.setState({ + projectBinding: { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Mac Studio", + projectId: "project-1", + rootPath: "/repo", + displayName: "Repo", + }, + } as any); + + renderSidebar({ + tab: "browser", + contextTarget: { kind: "chat", sessionId: "chat-1" }, + onTabChange, + }); + + expect(screen.getByRole("button", { name: "Git" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Files" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "iOS Sim" })).toBeNull(); + expect(screen.queryByRole("button", { name: "App Control" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Browser" })).toBeNull(); + expect(screen.queryByTestId("browser-panel")).toBeNull(); + await waitFor(() => expect(onTabChange).toHaveBeenCalledWith("git")); + expect(window.ade.builtInBrowser.getStatus).not.toHaveBeenCalled(); + expect(window.ade.iosSimulator.getStatus).not.toHaveBeenCalled(); + expect(window.ade.appControl.getStatus).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx index 59b1281b8..594ae8a95 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx @@ -79,6 +79,12 @@ const WORK_SIDEBAR_TABS: Array> = [ }, ]; +const REMOTE_WORK_SIDEBAR_TAB_IDS = new Set(["git", "files"]); + +function isRemoteWorkSidebarTab(tab: WorkSidebarTab): boolean { + return REMOTE_WORK_SIDEBAR_TAB_IDS.has(tab); +} + export type WorkSidebarContextTarget = | { kind: "chat"; sessionId: string } | { kind: "draft"; draftTargetId: string; laneId: string; draftKind: WorkDraftKind } @@ -214,8 +220,16 @@ export function WorkSidebar({ const [iosSession, setIosSession] = useState(null); const [browserStatus, setBrowserStatus] = useState(null); const projectRoot = useAppStore(selectActiveProjectRoot); + const isRemoteProject = useAppStore((state) => state.projectBinding?.kind === "remote"); const sidebarRef = useRef(null); const [compactTabs, setCompactTabs] = useState(false); + const sidebarTabs = useMemo( + () => isRemoteProject + ? WORK_SIDEBAR_TABS.filter((item) => isRemoteWorkSidebarTab(item.id)) + : WORK_SIDEBAR_TABS, + [isRemoteProject], + ); + const effectiveTab: WorkSidebarTab = isRemoteProject && !isRemoteWorkSidebarTab(tab) ? "git" : tab; const activeLane = useMemo( () => (laneId ? lanes.find((lane) => lane.id === laneId) ?? null : null), @@ -229,6 +243,12 @@ export function WorkSidebar({ setSelectedCommit(null); }, [laneId]); + useEffect(() => { + if (isRemoteProject && !isRemoteWorkSidebarTab(tab)) { + onTabChange("git"); + } + }, [isRemoteProject, onTabChange, tab]); + useEffect(() => { const el = sidebarRef.current; if (!el) return undefined; @@ -242,20 +262,20 @@ export function WorkSidebar({ return () => observer.disconnect(); }, []); - const previousBrowserTabRef = useRef(tab === "browser"); + const previousBrowserTabRef = useRef(effectiveTab === "browser"); useEffect(() => { const wasBrowser = previousBrowserTabRef.current; - const isBrowser = active && tab === "browser"; + const isBrowser = active && effectiveTab === "browser"; if (wasBrowser && !isBrowser) hideBuiltInBrowserView(projectRoot); previousBrowserTabRef.current = isBrowser; return () => { if (previousBrowserTabRef.current) hideBuiltInBrowserView(projectRoot); }; - }, [active, projectRoot, tab]); + }, [active, effectiveTab, projectRoot]); useEffect(() => { if (!active) return undefined; - if (tab !== "browser") return undefined; + if (effectiveTab !== "browser") return undefined; const browser = window.ade?.builtInBrowser; if (!browser?.getStatus || !browser.onEvent) return undefined; let cancelled = false; @@ -278,11 +298,11 @@ export function WorkSidebar({ cancelled = true; unsubscribe(); }; - }, [active, projectRoot, tab]); + }, [active, effectiveTab, projectRoot]); useEffect(() => { if (!active) return undefined; - if (tab !== "app-control") return undefined; + if (effectiveTab !== "app-control") return undefined; const appControl = window.ade?.appControl; if (!appControl?.getStatus || !appControl.onEvent) return undefined; let cancelled = false; @@ -304,11 +324,11 @@ export function WorkSidebar({ cancelled = true; unsubscribe(); }; - }, [active, tab]); + }, [active, effectiveTab]); useEffect(() => { if (!active) return undefined; - if (tab !== "ios") return undefined; + if (effectiveTab !== "ios") return undefined; const iosSimulator = window.ade?.iosSimulator; if (!iosSimulator?.getStatus || !iosSimulator.onEvent) return undefined; let cancelled = false; @@ -330,14 +350,14 @@ export function WorkSidebar({ cancelled = true; unsubscribe(); }; - }, [active, tab]); + }, [active, effectiveTab]); function resolveToolAttributionReason(): string | null { if (!laneId) return null; - if (tab === "app-control" && appControlSession?.laneId && appControlSession.laneId !== laneId) { + if (effectiveTab === "app-control" && appControlSession?.laneId && appControlSession.laneId !== laneId) { return laneMismatchMessage("App Control", appControlSession.laneId, laneId, lanes); } - if (tab === "ios" && iosSession?.laneId && iosSession.laneId !== laneId) { + if (effectiveTab === "ios" && iosSession?.laneId && iosSession.laneId !== laneId) { return laneMismatchMessage("iOS Simulator", iosSession.laneId, laneId, lanes); } return null; @@ -459,7 +479,7 @@ export function WorkSidebar({ const content = useMemo(() => { if (!active) return null; - if (tab === "browser") { + if (effectiveTab === "browser") { return (
{warningReason ? : null} @@ -483,7 +503,7 @@ export function WorkSidebar({ ); } - if (tab === "git") { + if (effectiveTab === "git") { const hasDiffSelection = Boolean(selectedPath || selectedCommit); return (
@@ -530,11 +550,11 @@ export function WorkSidebar({ ); } - if (tab === "files") { + if (effectiveTab === "files") { return ; } - const panel = tab === "ios" ? ( + const panel = effectiveTab === "ios" ? ( { - if (tab === "browser" && nextTab !== "browser") hideBuiltInBrowserView(projectRoot); + if (effectiveTab === "browser" && nextTab !== "browser") hideBuiltInBrowserView(projectRoot); onTabChange(nextTab); }} /> @@ -603,7 +623,7 @@ export function WorkSidebar({ className="ade-shell-control inline-flex w-9 shrink-0 items-center justify-center self-stretch rounded-none border-l border-white/[0.08] text-muted-fg/70 transition-colors hover:bg-white/[0.04] hover:text-fg" data-variant="ghost" onClick={() => { - if (tab === "browser") hideBuiltInBrowserView(projectRoot); + if (effectiveTab === "browser") hideBuiltInBrowserView(projectRoot); onClose(); }} title="Close Tools sidebar" From 2f54d50c7ada1738636f854581ca41855bfc247d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:49:56 -0400 Subject: [PATCH 51/80] fix(work): defer mounted terminal teardown on project clear --- .../terminals/TerminalView.test.tsx | 39 ++++++++++++++++++- .../components/terminals/TerminalView.tsx | 2 +- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index 5b1631350..e3641a571 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -13,7 +13,7 @@ const mockState = vi.hoisted(() => ({ lastContextLossHandler: null as (() => void) | null, ptyDataListeners: new Set<(event: { ptyId: string; sessionId?: string; projectRoot?: string; data: string }) => void>(), ptyExitListeners: new Set<(event: { ptyId: string; sessionId?: string; projectRoot?: string; exitCode: number | null }) => void>(), - projectRoot: "/project/a", + projectRoot: "/project/a" as string | null, projectRevision: 0, theme: "dark" as const, terminalPreferences: { @@ -1301,6 +1301,43 @@ describe("TerminalView", () => { expect(getTerminalRuntimeSnapshot("session-switch")).not.toBeNull(); }); + it("does not tear down a mounted exited runtime while the active project clears", async () => { + const view = render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + dispose: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + + for (const listener of mockState.ptyExitListeners) { + listener({ + ptyId: "pty-project-clear", + sessionId: "session-project-clear", + projectRoot: "/project/a", + exitCode: 0, + }); + } + await flushPromises(); + expect(getTerminalRuntimeSnapshot("session-project-clear")?.exitCode).toBe(0); + + mockState.projectRoot = null; + mockState.projectRevision += 1; + view.rerender(); + await flushAnimationFrame(); + + expect(terminal?.dispose).not.toHaveBeenCalled(); + expect(getTerminalRuntimeSnapshot("session-project-clear")).not.toBeNull(); + + view.unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(15_100); + }); + + expect(terminal?.dispose).toHaveBeenCalledTimes(1); + expect(getTerminalRuntimeSnapshot("session-project-clear")).toBeNull(); + }); + it("hydrates live terminals from serialized snapshots when structured rows are unavailable", async () => { const previewMock = window.ade.terminal.preview as unknown as ReturnType; const readTranscriptTailMock = window.ade.sessions.readTranscriptTail as unknown as ReturnType; diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index 89d060770..51e211ae8 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -633,7 +633,7 @@ function disposeStaleRuntimes(activeProjectRoot: string | null, activeProjectRev for (const runtime of runtimeCache.values()) { const isLiveRuntime = runtime.exitCode == null; if (activeProjectRoot == null) { - if (runtime.projectRoot != null && !isLiveRuntime) { + if (runtime.projectRoot != null && !isLiveRuntime && runtime.refs === 0) { teardownRuntime(runtime); } continue; From d07ce4480635202ebf930de426b31df8b3f72523 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:47:17 -0400 Subject: [PATCH 52/80] fix(remote): refresh runtime artifacts and pty host --- .../src/main/services/pty/ptyService.test.ts | 13 ++ .../src/main/services/pty/ptyService.ts | 2 + .../main/services/pty/supervisedPtyHost.ts | 10 + .../remoteRuntime/remoteBootstrap.test.ts | 197 +++++++++++++++- .../services/remoteRuntime/remoteBootstrap.ts | 211 ++++++++++++------ 5 files changed, 353 insertions(+), 80 deletions(-) diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 68b941601..ce9bc58a1 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -4014,6 +4014,19 @@ describe("ptyService", () => { ); }); + it("ignores stale dispose after a PTY already exited", async () => { + const { service, mockPty, broadcastExit, sessionService } = createHarness(); + const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + mockPty._emitter.emit("exit", { exitCode: 0 }); + const endCalls = sessionService.end.mock.calls.length; + const exitCalls = broadcastExit.mock.calls.length; + + service.dispose({ ptyId, sessionId }); + + expect(sessionService.end).toHaveBeenCalledTimes(endCalls); + expect(broadcastExit).toHaveBeenCalledTimes(exitCalls); + }); + it("marks session as failed when exit code is non-zero", async () => { const { service, mockPty, sessionService } = createHarness(); const { sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 40b90f702..cd621ec6d 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -4708,6 +4708,8 @@ export function createPtyService({ if (!sessionId) return; const session = sessionService.get(sessionId); if (!session) return; + if (session.status && session.status !== "running") return; + if (session.ptyId && session.ptyId !== ptyId) return; if ( ownerPid != null && session.ownerPid != null diff --git a/apps/desktop/src/main/services/pty/supervisedPtyHost.ts b/apps/desktop/src/main/services/pty/supervisedPtyHost.ts index 19147fa69..f4b9194a7 100644 --- a/apps/desktop/src/main/services/pty/supervisedPtyHost.ts +++ b/apps/desktop/src/main/services/pty/supervisedPtyHost.ts @@ -81,6 +81,8 @@ function hostErrorToError(prefix: string, payload?: HostErrorPayload): Error { } function resolvePtyHostWorkerPath(): string { + const configured = process.env.ADE_PTY_HOST_WORKER_PATH?.trim(); + if (configured) return configured; const candidates = [ path.join(__dirname, "ptyHostWorker.cjs"), path.join(process.cwd(), "dist", "main", "ptyHostWorker.cjs"), @@ -94,6 +96,11 @@ function resolvePtyHostWorkerPath(): string { return candidates[0]!; } +function resolvePtyHostWorkerNodePath(): string | undefined { + const configured = process.env.ADE_PTY_HOST_WORKER_NODE?.trim(); + return configured || undefined; +} + function trimWorkerLogLine(text: string): string { const collapsed = text.replace(/\s+/g, " ").trim(); return collapsed.length > 2_000 ? `${collapsed.slice(0, 2_000)}...` : collapsed; @@ -387,9 +394,11 @@ class SupervisedPtyHost { } private startChildForPty(ptyId: string): HostChildState { + const workerNodePath = resolvePtyHostWorkerNodePath(); const child = fork(this.workerPath, [], { stdio: ["ignore", "pipe", "pipe", "ipc"], execArgv: [], + ...(workerNodePath ? { execPath: workerNodePath } : {}), env: { ...process.env, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}), @@ -406,6 +415,7 @@ class SupervisedPtyHost { this.restartCount += 1; this.logger.info("pty.host_started", { workerPath: this.workerPath, + workerNodePath: workerNodePath ?? null, restartCount: this.restartCount, ptyId, }); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 5a4abd58a..4f097300a 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -184,6 +184,16 @@ describe("buildRemoteRuntimeEnvironmentPrefix", () => { })).toContain('NODE_PATH="$HOME/.ade/runtime/darwin-arm64/node_modules${NODE_PATH:+:$NODE_PATH}"'); }); + it("adds the uploaded PTY host worker path when the worker artifact is ready", () => { + expect(buildRemoteRuntimeEnvironmentPrefix({ + archLabel: "darwin-arm64", + nativeDepsReady: true, + ptyHostWorkerReady: true, + ptyHostWorkerNodePath: "/usr/local/bin/node", + layout: resolveRemoteRuntimeLayout({} as NodeJS.ProcessEnv), + })).toContain('ADE_PTY_HOST_WORKER_PATH="$HOME/.ade/runtime/ptyHostWorker.cjs" ADE_PTY_HOST_WORKER_NODE=\'/usr/local/bin/node\''); + }); + it("can suppress service installation for shared runtime fallback sessions", () => { expect(buildRemoteRuntimeEnvironmentPrefix({ archLabel: "linux-x64", @@ -356,8 +366,15 @@ function createFakeSpawnProcess(options: { closeCode?: number; error?: Error; st function createTempResources( archLabel = "linux-x64", - options: { nativeDeps?: boolean } = {}, -): { resourcesPath: string; binaryPath: string; binarySha256: string; cleanup: () => void } { + options: { nativeDeps?: boolean; ptyHostWorker?: boolean } = {}, +): { + resourcesPath: string; + binaryPath: string; + binarySha256: string; + ptyHostWorkerPath: string | null; + ptyHostWorkerSha256: string | null; + cleanup: () => void; +} { const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-remote-runtime-")); const runtimeDir = path.join(resourcesPath, "runtime"); fs.mkdirSync(runtimeDir, { recursive: true }); @@ -366,11 +383,22 @@ function createTempResources( if (options.nativeDeps) { fs.writeFileSync(path.join(runtimeDir, `ade-${archLabel}.native.tar.gz`), "native deps fixture\n"); } + let ptyHostWorkerPath: string | null = null; + let ptyHostWorkerSha256: string | null = null; + if (options.ptyHostWorker) { + const adeCliDir = path.join(resourcesPath, "ade-cli"); + fs.mkdirSync(adeCliDir, { recursive: true }); + ptyHostWorkerPath = path.join(adeCliDir, "ptyHostWorker.cjs"); + fs.writeFileSync(ptyHostWorkerPath, "process.on('message', () => {});\n"); + ptyHostWorkerSha256 = crypto.createHash("sha256").update(fs.readFileSync(ptyHostWorkerPath)).digest("hex"); + } const binarySha256 = crypto.createHash("sha256").update(fs.readFileSync(binaryPath)).digest("hex"); return { resourcesPath, binaryPath, binarySha256, + ptyHostWorkerPath, + ptyHostWorkerSha256, cleanup: () => fs.rmSync(resourcesPath, { recursive: true, force: true }), }; } @@ -606,7 +634,77 @@ describe("bootstrapRemoteRuntime upload flow", () => { expect(fakeSsh.end).not.toHaveBeenCalled(); }); - it("tries a same-version runtime before replacing it for a hash-only mismatch", async () => { + it("uploads the PTY host worker and points the remote runtime at it", async () => { + const resources = createTempResources("linux-x64", { ptyHostWorker: true }); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + }); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(`${APP_VERSION}\n`); + if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(`${resources.binarySha256}\n`); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(`ade ${APP_VERSION}\n`); + if (command === "command -v node || true") return ok("/usr/local/bin/node\n"); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade") && + command.includes("echo ok") + ) return ok("ok\n"); + if (command.includes("test -f $HOME/.ade/runtime/ptyHostWorker.cjs")) return ok(""); + if (command === "mkdir -p $HOME/.ade/runtime") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/runtime\/ptyHostWorker\.cjs\.upload-.* && umask 077 && : > \$HOME\/\.ade\/runtime\/ptyHostWorker\.cjs\.upload-.* && chmod 600 \$HOME\/\.ade\/runtime\/ptyHostWorker\.cjs\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/runtime/ptyHostWorker.cjs.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.ptyHostWorkerPath!).size}\n`); + if ( + command.includes("wc -c < $HOME/.ade/runtime/ptyHostWorker.cjs.upload-") && + command.includes("shasum -a 256 $HOME/.ade/runtime/ptyHostWorker.cjs.upload-") && + command.includes("mv -f $HOME/.ade/runtime/ptyHostWorker.cjs.upload-") && + command.includes(`printf '%s\\n' '${resources.ptyHostWorkerSha256}' > $HOME/.ade/runtime/ptyHostWorker.cjs.sha256`) + ) return ok(""); + throw new Error(`Unexpected SSH command: ${command}`); + }); + + const connected = await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + resources.ptyHostWorkerPath, + expect.stringMatching(/^\/home\/ade\/\.ade\/runtime\/ptyHostWorker\.cjs\.upload-.*\.tmp$/), + expect.objectContaining({ fileSize: fs.statSync(resources.ptyHostWorkerPath!).size, mode: 0o600 }), + expect.any(Function), + ); + expect(commands.some((command) => + command.includes("wc -c < $HOME/.ade/runtime/ptyHostWorker.cjs.upload-") && + command.includes(`printf '%s\\n' '${resources.ptyHostWorkerSha256}' > $HOME/.ade/runtime/ptyHostWorker.cjs.sha256`) && + command.includes("mv -f $HOME/.ade/runtime/ptyHostWorker.cjs.upload-"), + )).toBe(true); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PTY_HOST_WORKER_PATH="$HOME/.ade/runtime/ptyHostWorker.cjs" ADE_PTY_HOST_WORKER_NODE=\'/usr/local/bin/node\' $HOME/.ade/bin/ade rpc --stdio', + ); + expect(connected.result).toMatchObject({ + arch: "linux-x64", + version: APP_VERSION, + projects: [{ projectId: "project-1", rootPath: "/srv/ade" }], + }); + }); + + it("uploads a same-version runtime when the bundled binary hash changed", async () => { const resources = createTempResources(); cleanupResources = resources.cleanup; const fakeSsh = createFakeSsh(); @@ -618,15 +716,32 @@ describe("bootstrapRemoteRuntime upload flow", () => { const commands: string[] = []; execSshMock.mockImplementation(async (_client: Client, command: string) => { commands.push(command); + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; if (command === "uname -sm") return ok("Linux x86_64\n"); if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(`${APP_VERSION}\n`); if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok("previous-local-build-sha\n"); - if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok("ade 0.0.0\n"); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(`ade ${APP_VERSION}\n`); if ( command.includes("wc -c < $HOME/.ade/bin/ade") && command.includes("shasum -a 256 $HOME/.ade/bin/ade") && command.includes("echo ok") ) return ok(""); + if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade\/bin\/ade\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.binaryPath).size}\n`); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade.upload-") && + command.includes("mv -f $HOME/.ade/bin/ade.upload-") && + command.includes(`printf '%s\\n' '${APP_VERSION}' > $HOME/.ade/bin/ade.version`) + ) return ok(""); + if (command.includes("$HOME/.ade/bin/ade --version")) return ok(`ade ${APP_VERSION}\n`); + if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); throw new Error(`Unexpected SSH command: ${command}`); }); @@ -637,10 +752,78 @@ describe("bootstrapRemoteRuntime upload flow", () => { appVersion: APP_VERSION, }); - expect(fakeSsh.exec).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); - expect(commands).not.toContain("mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin"); - expect(commands.some((command) => command.includes("$HOME/.ade/bin/ade.upload-"))).toBe(false); + expect(commands).toContain("mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin"); + expect(commands.some((command) => + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes(`printf '%s\\n' '${resources.binarySha256}' > $HOME/.ade/bin/ade.sha256`) && + command.includes("mv -f $HOME/.ade/bin/ade.upload-"), + )).toBe(true); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade rpc --stdio', + ); + expect(connected.result).toMatchObject({ + arch: "linux-x64", + version: APP_VERSION, + projects: [{ projectId: "project-1", rootPath: "/srv/ade" }], + }); + }); + + it("replaces a placeholder runtime even when its marker matches the desktop version", async () => { + const resources = createTempResources(); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + }); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(`${APP_VERSION}\n`); + if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok("previous-local-build-sha\n"); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok("ade 0.0.0\n"); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade") && + command.includes("echo ok") + ) return ok(""); + if (command === "mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin") return ok(""); + if (command.match(/^rm -f \$HOME\/\.ade\/bin\/ade\.upload-.* && umask 077 && : > \$HOME\/\.ade\/bin\/ade\.upload-.* && chmod 600 \$HOME\/\.ade\/bin\/ade\.upload-/)) return ok(""); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + !command.includes("shasum") && + !command.includes("mv -f") + ) return ok(`${fs.statSync(resources.binaryPath).size}\n`); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade.upload-") && + command.includes("mv -f $HOME/.ade/bin/ade.upload-") && + command.includes(`printf '%s\\n' '${APP_VERSION}' > $HOME/.ade/bin/ade.version`) + ) return ok(""); + if (command.includes("$HOME/.ade/bin/ade --version")) return ok(`ade ${APP_VERSION}\n`); + if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); + throw new Error(`Unexpected SSH command: ${command}`); + }); + + const connected = await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(commands).toContain("mkdir -p $HOME/.ade/bin && chmod 700 $HOME/.ade/bin"); + expect(commands.some((command) => + command.includes("wc -c < $HOME/.ade/bin/ade.upload-") && + command.includes(`printf '%s\\n' '${resources.binarySha256}' > $HOME/.ade/bin/ade.sha256`) && + command.includes("mv -f $HOME/.ade/bin/ade.upload-"), + )).toBe(true); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade rpc --stdio', diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index a93739248..13cb28b81 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -171,6 +171,8 @@ type RemoteRuntimeLayout = { binaryRelative: string; versionExpr: string; sha256Expr: string; + ptyHostWorkerExpr: string; + ptyHostWorkerSha256Expr: string; }; function normalizeRemoteRuntimeChannel(value: unknown): RemoteRuntimeChannel { @@ -201,6 +203,8 @@ export function resolveRemoteRuntimeLayout(env: NodeJS.ProcessEnv = process.env) binaryRelative: `${homeDirName}/bin/ade`, versionExpr: `${binDirExpr}/ade.version`, sha256Expr: `${binDirExpr}/ade.sha256`, + ptyHostWorkerExpr: `${runtimeDirExpr}/ptyHostWorker.cjs`, + ptyHostWorkerSha256Expr: `${runtimeDirExpr}/ptyHostWorker.cjs.sha256`, }; } @@ -225,6 +229,8 @@ export function buildRemoteRuntimeEnvironmentPrefix(args: { nativeDepsReady: boolean; layout?: RemoteRuntimeLayout; disableRuntimeServiceInstall?: boolean; + ptyHostWorkerReady?: boolean; + ptyHostWorkerNodePath?: string | null; }): string { const layout = args.layout ?? resolveRemoteRuntimeLayout(); const parts = [ @@ -241,6 +247,12 @@ export function buildRemoteRuntimeEnvironmentPrefix(args: { if (args.nativeDepsReady) { parts.push(`NODE_PATH="${layout.runtimeDirExpr}/${args.archLabel}/node_modules${"${NODE_PATH:+:$NODE_PATH}"}"`); } + if (args.ptyHostWorkerReady) { + parts.push(`ADE_PTY_HOST_WORKER_PATH="${layout.ptyHostWorkerExpr}"`); + if (args.ptyHostWorkerNodePath) { + parts.push(`ADE_PTY_HOST_WORKER_NODE=${shellQuote(args.ptyHostWorkerNodePath)}`); + } + } return `${parts.join(" ")} `; } @@ -279,6 +291,31 @@ function bundledNativeDepsPath(resourcesPath: string, archLabel: string): string }) ?? null; } +function bundledPtyHostWorkerPath(resourcesPath: string, localBinaryPath: string | null): string | null { + const candidates = [ + path.join(resourcesPath, "ade-cli", "ptyHostWorker.cjs"), + path.join(resourcesPath, "app.asar.unpacked", "ade-cli", "ptyHostWorker.cjs"), + ]; + if (localBinaryPath) { + const localBinaryDir = path.dirname(localBinaryPath); + const desktopRuntimeDir = path.resolve(process.cwd(), "resources", "runtime"); + const repoRuntimeDir = path.resolve(process.cwd(), "apps", "desktop", "resources", "runtime"); + if (localBinaryDir === desktopRuntimeDir) { + candidates.push(path.resolve(process.cwd(), "../ade-cli/dist/ptyHostWorker.cjs")); + } + if (localBinaryDir === repoRuntimeDir) { + candidates.push(path.resolve(process.cwd(), "apps/ade-cli/dist/ptyHostWorker.cjs")); + } + } + return candidates.find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }) ?? null; +} + function hashRuntimeBinary(localPath: string): string { return crypto.createHash("sha256").update(fs.readFileSync(localPath)).digest("hex"); } @@ -872,6 +909,37 @@ async function uploadNativeDepsBundle( } } +async function uploadPtyHostWorker( + client: Client, + target: RemoteRuntimeTarget, + route: ConnectedSshRoute, + connectedConfig: Pick | null | undefined, + layout: RemoteRuntimeLayout, + localPath: string, + localSha256: string, +): Promise { + const tempSuffix = remoteUploadTempSuffix(); + const tempExpr = `${layout.ptyHostWorkerExpr}.${tempSuffix}`; + await execSshOrThrow( + client, + `mkdir -p ${layout.runtimeDirExpr}`, + "Unable to create the remote ADE runtime artifact directory.", + ); + try { + await uploadSshFile(client, target, route, connectedConfig, localPath, tempExpr); + await execSshOrThrow(client, [ + remoteFileMatchesCommand(tempExpr, fileSizeBytes(localPath), localSha256), + `mv -f ${tempExpr} ${layout.ptyHostWorkerExpr}`, + `printf '%s\\n' ${shellQuote(localSha256)} > ${layout.ptyHostWorkerSha256Expr}`, + `chmod 600 ${layout.ptyHostWorkerExpr}`, + `chmod 600 ${layout.ptyHostWorkerSha256Expr}`, + ].join(" && "), "Uploaded ADE PTY host worker did not pass size and checksum verification."); + } catch (error) { + await execSsh(client, `rm -f ${tempExpr}`).catch(() => okIgnored()); + throw error; + } +} + async function signUploadedRuntimeBinaryIfNeeded(client: Client, layout: RemoteRuntimeLayout, platform: string): Promise { if (platform !== "darwin") return; const signed = await execSsh(client, `codesign --force --sign - ${layout.binaryExpr}`); @@ -1000,6 +1068,8 @@ export async function bootstrapRemoteRuntime(args: { const localBinary = bundledRuntimePath(args.resourcesPath, arch.label); const localBinarySha256 = localBinary ? hashRuntimeBinary(localBinary) : null; const nativeDepsBundle = bundledNativeDepsPath(args.resourcesPath, arch.label); + const localPtyHostWorker = bundledPtyHostWorkerPath(args.resourcesPath, localBinary); + const localPtyHostWorkerSha256 = localPtyHostWorker ? hashRuntimeBinary(localPtyHostWorker) : null; let remoteBinaryMatchesLocal: boolean | null = null; if (!localBinary && !executableRuntimeVersion) { @@ -1046,7 +1116,7 @@ export async function bootstrapRemoteRuntime(args: { } } - let shouldUploadRuntime = Boolean(localBinary && localBinarySha256 && shouldUploadBundledRuntime({ + const shouldUploadRuntime = Boolean(localBinary && localBinarySha256 && shouldUploadBundledRuntime({ localBinaryAvailable: true, executableVersion: executableRuntimeVersion, markerVersion: markedRuntimeVersion, @@ -1055,16 +1125,6 @@ export async function bootstrapRemoteRuntime(args: { remoteBinarySha256, remoteBinaryMatchesLocal, })); - const deferSameVersionUpload = Boolean( - shouldUploadRuntime - && localBinary - && localBinarySha256 - && runtimeVersion === args.appVersion - && executableRuntimeVersion, - ); - if (deferSameVersionUpload) { - shouldUploadRuntime = false; - } const uploadBundledRuntime = async (): Promise => { if (!localBinary || !localBinarySha256) return; @@ -1094,11 +1154,40 @@ export async function bootstrapRemoteRuntime(args: { let nativeDepsReady = await ensureNativeDepsReady(runtimeUploaded); + let ptyHostWorkerNodePath: string | null = null; + const ensurePtyHostWorkerReady = async (forceUpload: boolean): Promise => { + if (!localPtyHostWorker || !localPtyHostWorkerSha256) return false; + const ptyHostWorkerCheck = await execSsh(ssh, [ + `test -f ${layout.ptyHostWorkerExpr}`, + `test "$(cat ${layout.ptyHostWorkerSha256Expr} 2>/dev/null)" = ${shellQuote(localPtyHostWorkerSha256)}`, + "echo ok", + ].join(" && ") + " || true"); + const shouldUploadPtyHostWorker = forceUpload || ptyHostWorkerCheck.stdout.trim() !== "ok"; + if (shouldUploadPtyHostWorker) { + await uploadPtyHostWorker( + ssh, + args.target, + connectedRoute, + connectedConfig, + layout, + localPtyHostWorker, + localPtyHostWorkerSha256, + ); + } + const nodePathCheck = await execSsh(ssh, "command -v node || true"); + ptyHostWorkerNodePath = nodePathCheck.stdout.trim().split(/\r?\n/u)[0]?.trim() || null; + return true; + }; + + const ptyHostWorkerReady = await ensurePtyHostWorkerReady(runtimeUploaded); + let runtimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ archLabel: arch.label, nativeDepsReady, layout, disableRuntimeServiceInstall: layout.homeDirName !== preferredLayout.homeDirName, + ptyHostWorkerReady, + ptyHostWorkerNodePath, }); const verifyUploadedRuntime = async (): Promise => { @@ -1143,74 +1232,50 @@ export async function bootstrapRemoteRuntime(args: { expectedLayout: layout, }); } catch (error) { - let sameVersionUploadRecovered = false; - if (deferSameVersionUpload && localBinary && localBinarySha256) { - await uploadBundledRuntime(); - nativeDepsReady = await ensureNativeDepsReady(true); - runtimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ - archLabel: arch.label, - nativeDepsReady, - layout, - disableRuntimeServiceInstall: layout.homeDirName !== preferredLayout.homeDirName, - }); - await verifyUploadedRuntime(); - await stopRemoteRuntimeDaemon(ssh, layout, runtimeEnvPrefix); - openedRuntime = await openValidatedRuntimeClient({ + if (localBinary || runtimeUploaded) { + throw error; + } + const attempted = [{ layout: layout.homeDirName, error: runtimeErrorMessage(error) }]; + for (const candidateLayout of resolveRemoteRuntimeLayoutCandidates().filter((candidate) => candidate.homeDirName !== layout.homeDirName)) { + const candidateVersionCheck = await execSsh( + ssh, + `test -x ${candidateLayout.binaryExpr} && ${candidateLayout.binaryExpr} --version || true`, + ); + const candidateRuntimeVersion = normalizeRuntimeVersion(candidateVersionCheck.stdout); + if (!candidateRuntimeVersion) continue; + const candidateNativeDepsCheck = await execSsh( ssh, - command: `${runtimeEnvPrefix}${layout.binaryExpr} rpc --stdio`, - appVersion: args.appVersion, - expectedVersion, - expectedLayout: layout, + `test -d ${candidateLayout.runtimeDirExpr}/${arch.label}/node_modules && echo ok || true`, + ); + const candidateRuntimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ + archLabel: arch.label, + nativeDepsReady: candidateNativeDepsCheck.stdout.trim() === "ok", + layout: candidateLayout, + disableRuntimeServiceInstall: true, }); - runtimeLayoutFallbackReason = `Updated remote runtime in ${layout.homeDirName} because the existing same-version ADE service could not start compatible RPC: ${runtimeErrorMessage(error)}`; - sameVersionUploadRecovered = true; - } - if (!sameVersionUploadRecovered) { - if (localBinary || runtimeUploaded) { - throw error; - } - const attempted = [{ layout: layout.homeDirName, error: runtimeErrorMessage(error) }]; - for (const candidateLayout of resolveRemoteRuntimeLayoutCandidates().filter((candidate) => candidate.homeDirName !== layout.homeDirName)) { - const candidateVersionCheck = await execSsh( - ssh, - `test -x ${candidateLayout.binaryExpr} && ${candidateLayout.binaryExpr} --version || true`, - ); - const candidateRuntimeVersion = normalizeRuntimeVersion(candidateVersionCheck.stdout); - if (!candidateRuntimeVersion) continue; - const candidateNativeDepsCheck = await execSsh( + const candidateCommand = `${candidateRuntimeEnvPrefix}ade rpc --stdio`; + try { + openedRuntime = await openValidatedRuntimeClient({ ssh, - `test -d ${candidateLayout.runtimeDirExpr}/${arch.label}/node_modules && echo ok || true`, - ); - const candidateRuntimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ - archLabel: arch.label, - nativeDepsReady: candidateNativeDepsCheck.stdout.trim() === "ok", - layout: candidateLayout, - disableRuntimeServiceInstall: true, + command: candidateCommand, + appVersion: args.appVersion, + expectedVersion: null, + expectedLayout: candidateLayout, }); - const candidateCommand = `${candidateRuntimeEnvPrefix}ade rpc --stdio`; - try { - openedRuntime = await openValidatedRuntimeClient({ - ssh, - command: candidateCommand, - appVersion: args.appVersion, - expectedVersion: null, - expectedLayout: candidateLayout, - }); - runtimeLayoutFallbackReason = `Using remote runtime home ${candidateLayout.homeDirName} because ${layout.homeDirName} could not start a compatible ADE RPC service: ${runtimeErrorMessage(error)}`; - layout = candidateLayout; - runtimeVersion = candidateRuntimeVersion; - break; - } catch (candidateError) { - attempted.push({ layout: candidateLayout.homeDirName, error: runtimeErrorMessage(candidateError) }); - } - } - if (!openedRuntime) { - throw new Error( - "Remote ADE service could not start a compatible RPC runtime. " + - `Tried ${attempted.map((attempt) => `${attempt.layout}: ${attempt.error}`).join("; ")}.`, - ); + runtimeLayoutFallbackReason = `Using remote runtime home ${candidateLayout.homeDirName} because ${layout.homeDirName} could not start a compatible ADE RPC service: ${runtimeErrorMessage(error)}`; + layout = candidateLayout; + runtimeVersion = candidateRuntimeVersion; + break; + } catch (candidateError) { + attempted.push({ layout: candidateLayout.homeDirName, error: runtimeErrorMessage(candidateError) }); } } + if (!openedRuntime) { + throw new Error( + "Remote ADE service could not start a compatible RPC runtime. " + + `Tried ${attempted.map((attempt) => `${attempt.layout}: ${attempt.error}`).join("; ")}.`, + ); + } } if (!openedRuntime) { throw new Error("Remote ADE service could not start a compatible RPC runtime."); From 5ca0740dd70e12455be0aa870570fa1d43fe59fe Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:14:01 -0400 Subject: [PATCH 53/80] fix(remote): run pty worker from static runtime --- apps/ade-cli/src/cli.test.ts | 6 ++ apps/ade-cli/src/cli.ts | 15 ++++ .../services/pty/supervisedPtyHost.test.ts | 30 +++++++ .../main/services/pty/supervisedPtyHost.ts | 36 ++++++--- .../remoteRuntime/remoteBootstrap.test.ts | 81 ++++++++++++++++--- .../services/remoteRuntime/remoteBootstrap.ts | 15 +++- 6 files changed, 158 insertions(+), 25 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index f3af7f36c..b3d0a7824 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -275,6 +275,12 @@ describe("ADE CLI", () => { }); }); + it("recognizes the hidden PTY host worker entrypoint", () => { + expect(buildCliPlan(["__ade-pty-host-worker"])).toEqual({ + kind: "pty-host-worker", + }); + }); + it("classifies only ADE temp runtime sockets as ephemeral", () => { const tempSocket = path.join(os.tmpdir(), "ade-stdio-rpc-test", "sock", "ade.sock"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index d632b7cbb..5db6c4e3e 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -163,6 +163,7 @@ type CliPlan = | { kind: "runtime"; rest: string[] } | { kind: "serve"; rest: string[] } | { kind: "rpc-stdio"; rest: string[] } + | { kind: "pty-host-worker" } | { kind: "init"; targetPath: string | null } | { kind: "cursor-cloud"; rest: string[] } | { kind: "deeplink"; rest: string[] }; @@ -10422,6 +10423,9 @@ function buildCliPlan(command: string[]): CliPlan { if (primary === "version" || primary === "--version" || primary === "-v") { return { kind: "help", text: `ade ${VERSION}\n` }; } + if (primary === "__ade-pty-host-worker") { + return { kind: "pty-host-worker" }; + } if (primary === "code") { const rest = args; return { kind: "ade-code", rest }; @@ -15374,6 +15378,17 @@ async function runCli( await runNativeRpcStdio(parsed.options); return { output: "", exitCode: 0 }; } + if (plan.kind === "pty-host-worker") { + await import("../../desktop/src/main/services/pty/ptyHostWorker"); + await new Promise((resolve) => { + if (typeof process.send !== "function") { + resolve(); + return; + } + process.once("disconnect", resolve); + }); + return { output: "", exitCode: 0 }; + } if (plan.kind === "desktop") { const result = await runDesktopCommand(plan.rest); return { diff --git a/apps/desktop/src/main/services/pty/supervisedPtyHost.test.ts b/apps/desktop/src/main/services/pty/supervisedPtyHost.test.ts index c75fdb8d4..45434aa21 100644 --- a/apps/desktop/src/main/services/pty/supervisedPtyHost.test.ts +++ b/apps/desktop/src/main/services/pty/supervisedPtyHost.test.ts @@ -5,10 +5,12 @@ import { createSupervisedPtyLoader, type HostedPty } from "./supervisedPtyHost"; const mocks = vi.hoisted(() => ({ fork: vi.fn(), + spawn: vi.fn(), })); vi.mock("node:child_process", () => ({ fork: mocks.fork, + spawn: mocks.spawn, })); type FakeChild = EventEmitter & { @@ -68,10 +70,12 @@ function spawnOptions(): IWindowsPtyForkOptions { describe("createSupervisedPtyLoader", () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); }); afterEach(() => { vi.useRealTimers(); + vi.unstubAllEnvs(); }); it("queues PTY operations until the host confirms spawn", async () => { @@ -101,6 +105,32 @@ describe("createSupervisedPtyLoader", () => { ]); }); + it("can launch the worker through an internal ADE command instead of node fork", () => { + const child = createFakeChild(); + mocks.spawn.mockReturnValueOnce(child); + vi.stubEnv("ADE_PTY_HOST_WORKER_COMMAND", "/Users/example/.ade/bin/ade"); + vi.stubEnv("ADE_PTY_HOST_WORKER_NODE", ""); + const loader = createSupervisedPtyLoader({ logger: createLogger() as any }); + + loader().spawn("/bin/zsh", [], spawnOptions()); + + expect(mocks.spawn).toHaveBeenCalledWith( + "/Users/example/.ade/bin/ade", + ["__ade-pty-host-worker"], + expect.objectContaining({ + stdio: ["ignore", "pipe", "pipe", "ipc"], + env: expect.objectContaining({ + ADE_PTY_HOST: "1", + }), + }), + ); + expect(mocks.fork).not.toHaveBeenCalled(); + expect(child.sent[0]).toEqual(expect.objectContaining({ + type: "spawn", + command: "/bin/zsh", + })); + }); + it("converts host process exit into PTY exits and restarts on the next spawn", async () => { const child1 = createFakeChild(); const child2 = createFakeChild(); diff --git a/apps/desktop/src/main/services/pty/supervisedPtyHost.ts b/apps/desktop/src/main/services/pty/supervisedPtyHost.ts index f4b9194a7..52ed45f9c 100644 --- a/apps/desktop/src/main/services/pty/supervisedPtyHost.ts +++ b/apps/desktop/src/main/services/pty/supervisedPtyHost.ts @@ -1,4 +1,4 @@ -import { fork, type ChildProcess } from "node:child_process"; +import { fork, spawn, type ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; @@ -64,6 +64,7 @@ type HostChildState = { const HOST_KILL_GRACE_MS = 3_000; const HOST_SIGKILL_GRACE_MS = 500; const HOST_SPAWN_TIMEOUT_MS = 15_000; +const INTERNAL_PTY_HOST_WORKER_ARG = "__ade-pty-host-worker"; export type HostedPty = IPty & { __adePtyHostReady: Promise; @@ -101,6 +102,11 @@ function resolvePtyHostWorkerNodePath(): string | undefined { return configured || undefined; } +function resolvePtyHostWorkerCommand(): string | undefined { + const configured = process.env.ADE_PTY_HOST_WORKER_COMMAND?.trim(); + return configured || undefined; +} + function trimWorkerLogLine(text: string): string { const collapsed = text.replace(/\s+/g, " ").trim(); return collapsed.length > 2_000 ? `${collapsed.slice(0, 2_000)}...` : collapsed; @@ -395,16 +401,23 @@ class SupervisedPtyHost { private startChildForPty(ptyId: string): HostChildState { const workerNodePath = resolvePtyHostWorkerNodePath(); - const child = fork(this.workerPath, [], { - stdio: ["ignore", "pipe", "pipe", "ipc"], - execArgv: [], - ...(workerNodePath ? { execPath: workerNodePath } : {}), - env: { - ...process.env, - ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}), - ADE_PTY_HOST: "1", - }, - }); + const workerCommand = resolvePtyHostWorkerCommand(); + const workerEnv = { + ...process.env, + ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}), + ADE_PTY_HOST: "1", + }; + const child = workerCommand + ? spawn(workerCommand, [INTERNAL_PTY_HOST_WORKER_ARG], { + stdio: ["ignore", "pipe", "pipe", "ipc"], + env: workerEnv, + }) + : fork(this.workerPath, [], { + stdio: ["ignore", "pipe", "pipe", "ipc"], + execArgv: [], + ...(workerNodePath ? { execPath: workerNodePath } : {}), + env: workerEnv, + }); const childState: HostChildState = { child, ptyIds: new Set([ptyId]), @@ -416,6 +429,7 @@ class SupervisedPtyHost { this.logger.info("pty.host_started", { workerPath: this.workerPath, workerNodePath: workerNodePath ?? null, + workerCommand: workerCommand ?? null, restartCount: this.restartCount, ptyId, }); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 4f097300a..91d01f692 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -194,6 +194,16 @@ describe("buildRemoteRuntimeEnvironmentPrefix", () => { })).toContain('ADE_PTY_HOST_WORKER_PATH="$HOME/.ade/runtime/ptyHostWorker.cjs" ADE_PTY_HOST_WORKER_NODE=\'/usr/local/bin/node\''); }); + it("can point the PTY host worker at the internal ADE runtime command", () => { + expect(buildRemoteRuntimeEnvironmentPrefix({ + archLabel: "darwin-arm64", + nativeDepsReady: true, + ptyHostWorkerReady: true, + ptyHostWorkerCommandExpr: "$HOME/.ade/bin/ade", + layout: resolveRemoteRuntimeLayout({} as NodeJS.ProcessEnv), + })).toContain('ADE_PTY_HOST_WORKER_COMMAND="$HOME/.ade/bin/ade"'); + }); + it("can suppress service installation for shared runtime fallback sessions", () => { expect(buildRemoteRuntimeEnvironmentPrefix({ archLabel: "linux-x64", @@ -336,6 +346,11 @@ function resolvedRemotePath(command: string): ReturnType | null { return ok(command.slice("printf '%s' ".length).replace("$HOME", "/home/ade")); } +function defaultRemoteBootstrapCommand(command: string): ReturnType { + if (command === "command -v node || true") return ok("/usr/local/bin/node\n"); + throw new Error(`Unexpected SSH command: ${command}`); +} + function createFakeSpawnProcess(options: { closeCode?: number; error?: Error; stderr?: string } = {}) { const child = new EventEmitter() as EventEmitter & { stdin: EventEmitter & { @@ -569,7 +584,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { ) return ok(""); if (command.includes("$HOME/.ade/bin/ade --version")) return ok("ade 2.0.0\n"); if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); const connected = await bootstrapRemoteRuntime({ @@ -672,7 +687,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { command.includes("mv -f $HOME/.ade/runtime/ptyHostWorker.cjs.upload-") && command.includes(`printf '%s\\n' '${resources.ptyHostWorkerSha256}' > $HOME/.ade/runtime/ptyHostWorker.cjs.sha256`) ) return ok(""); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); const connected = await bootstrapRemoteRuntime({ @@ -704,6 +719,50 @@ describe("bootstrapRemoteRuntime upload flow", () => { }); }); + it("uses the internal ADE PTY host worker command when remote node is unavailable", async () => { + const resources = createTempResources("linux-x64"); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + }); + execSshMock.mockImplementation(async (_client: Client, command: string) => { + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(`${APP_VERSION}\n`); + if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(`${resources.binarySha256}\n`); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(`ade ${APP_VERSION}\n`); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade") && + command.includes("echo ok") + ) return ok("ok\n"); + if (command === "command -v node || true") return ok("\n"); + return defaultRemoteBootstrapCommand(command); + }); + + const connected = await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(fakeSsh.sftpWrapper.fastPut).not.toHaveBeenCalled(); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PTY_HOST_WORKER_COMMAND="$HOME/.ade/bin/ade" $HOME/.ade/bin/ade rpc --stdio', + ); + expect(connected.result).toMatchObject({ + arch: "linux-x64", + version: APP_VERSION, + projects: [{ projectId: "project-1", rootPath: "/srv/ade" }], + }); + }); + it("uploads a same-version runtime when the bundled binary hash changed", async () => { const resources = createTempResources(); cleanupResources = resources.cleanup; @@ -742,7 +801,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { ) return ok(""); if (command.includes("$HOME/.ade/bin/ade --version")) return ok(`ade ${APP_VERSION}\n`); if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); const connected = await bootstrapRemoteRuntime({ @@ -808,7 +867,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { ) return ok(""); if (command.includes("$HOME/.ade/bin/ade --version")) return ok(`ade ${APP_VERSION}\n`); if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); const connected = await bootstrapRemoteRuntime({ @@ -864,7 +923,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version") ) return ok(""); if (command.includes("$HOME/.ade/bin/ade --version")) return ok("ade 1.9.0\n"); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); await expect(bootstrapRemoteRuntime({ @@ -916,7 +975,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { !command.includes("mv -f") ) return ok("0\n"); if (command.startsWith("rm -f $HOME/.ade/bin/ade.upload-")) return ok(""); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); await expect(bootstrapRemoteRuntime({ @@ -993,7 +1052,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { if (command === "codesign --force --sign - $HOME/.ade-alpha/bin/ade") return ok(""); if (command.includes("$HOME/.ade-alpha/bin/ade --version")) return ok("ade 2.0.0\n"); if (command.includes("$HOME/.ade-alpha/bin/ade runtime stop --text")) return ok(""); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); await bootstrapRemoteRuntime({ @@ -1045,7 +1104,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { if (command === "test -x $HOME/.ade-alpha/bin/ade && $HOME/.ade-alpha/bin/ade --version || true") return ok("ade 1.9.0-alpha.4\n"); if (command === "cat $HOME/.ade-alpha/bin/ade.version 2>/dev/null || true") return ok("1.9.0-alpha.4\n"); if (command === "cat $HOME/.ade-alpha/bin/ade.sha256 2>/dev/null || true") return ok("old-sha\n"); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); initializeMock.mockResolvedValueOnce({ runtimeInfo: { version: "1.9.0-alpha.4", packageChannel: "alpha", multiProject: true }, @@ -1099,7 +1158,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); if (command === "test -x $HOME/.ade-alpha/bin/ade && $HOME/.ade-alpha/bin/ade --version || true") return ok("ade 1.9.0-alpha.4\n"); if (command === "test -d $HOME/.ade-alpha/runtime/linux-x64/node_modules && echo ok || true") return ok("ok\n"); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); initializeMock .mockResolvedValueOnce({ @@ -1164,7 +1223,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { if (command === "test -x $HOME/.ade-beta/bin/ade && $HOME/.ade-beta/bin/ade --version || true") return ok("ade 1.8.0-beta.2\n"); if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok("ade 1.9.0\n"); if (command === "test -d $HOME/.ade/runtime/linux-x64/node_modules && echo ok || true") return ok("ok\n"); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); initializeMock .mockResolvedValueOnce({ @@ -1223,7 +1282,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { command.includes("echo ok") ) return ok("ok\n"); if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); - throw new Error(`Unexpected SSH command: ${command}`); + return defaultRemoteBootstrapCommand(command); }); initializeMock.mockResolvedValueOnce({ runtimeInfo: { version: APP_VERSION, multiProject: true }, diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index 13cb28b81..01ee18012 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -231,6 +231,7 @@ export function buildRemoteRuntimeEnvironmentPrefix(args: { disableRuntimeServiceInstall?: boolean; ptyHostWorkerReady?: boolean; ptyHostWorkerNodePath?: string | null; + ptyHostWorkerCommandExpr?: string | null; }): string { const layout = args.layout ?? resolveRemoteRuntimeLayout(); const parts = [ @@ -248,9 +249,11 @@ export function buildRemoteRuntimeEnvironmentPrefix(args: { parts.push(`NODE_PATH="${layout.runtimeDirExpr}/${args.archLabel}/node_modules${"${NODE_PATH:+:$NODE_PATH}"}"`); } if (args.ptyHostWorkerReady) { - parts.push(`ADE_PTY_HOST_WORKER_PATH="${layout.ptyHostWorkerExpr}"`); if (args.ptyHostWorkerNodePath) { + parts.push(`ADE_PTY_HOST_WORKER_PATH="${layout.ptyHostWorkerExpr}"`); parts.push(`ADE_PTY_HOST_WORKER_NODE=${shellQuote(args.ptyHostWorkerNodePath)}`); + } else if (args.ptyHostWorkerCommandExpr) { + parts.push(`ADE_PTY_HOST_WORKER_COMMAND="${args.ptyHostWorkerCommandExpr}"`); } } return `${parts.join(" ")} `; @@ -1155,7 +1158,14 @@ export async function bootstrapRemoteRuntime(args: { let nativeDepsReady = await ensureNativeDepsReady(runtimeUploaded); let ptyHostWorkerNodePath: string | null = null; + let ptyHostWorkerCommandExpr: string | null = null; const ensurePtyHostWorkerReady = async (forceUpload: boolean): Promise => { + const nodePathCheck = await execSsh(ssh, "command -v node || true"); + ptyHostWorkerNodePath = nodePathCheck.stdout.trim().split(/\r?\n/u)[0]?.trim() || null; + if (!ptyHostWorkerNodePath) { + ptyHostWorkerCommandExpr = layout.binaryExpr; + return true; + } if (!localPtyHostWorker || !localPtyHostWorkerSha256) return false; const ptyHostWorkerCheck = await execSsh(ssh, [ `test -f ${layout.ptyHostWorkerExpr}`, @@ -1174,8 +1184,6 @@ export async function bootstrapRemoteRuntime(args: { localPtyHostWorkerSha256, ); } - const nodePathCheck = await execSsh(ssh, "command -v node || true"); - ptyHostWorkerNodePath = nodePathCheck.stdout.trim().split(/\r?\n/u)[0]?.trim() || null; return true; }; @@ -1188,6 +1196,7 @@ export async function bootstrapRemoteRuntime(args: { disableRuntimeServiceInstall: layout.homeDirName !== preferredLayout.homeDirName, ptyHostWorkerReady, ptyHostWorkerNodePath, + ptyHostWorkerCommandExpr, }); const verifyUploadedRuntime = async (): Promise => { From c325bf58f3db4871f0053208aee78e2c120748fe Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:48:55 -0400 Subject: [PATCH 54/80] fix(remote): stabilize runtime version and pty sessions --- apps/ade-cli/scripts/build-static.mjs | 42 +++++++++++++++++++ .../src/main/services/pty/ptyService.test.ts | 16 +++++++ .../src/main/services/pty/ptyService.ts | 8 +++- 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/scripts/build-static.mjs b/apps/ade-cli/scripts/build-static.mjs index 4f3c27b17..f11bd8d41 100644 --- a/apps/ade-cli/scripts/build-static.mjs +++ b/apps/ade-cli/scripts/build-static.mjs @@ -6,6 +6,9 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = path.resolve(packageRoot, "..", ".."); +const desktopPackageJsonPath = path.join(repoRoot, "apps", "desktop", "package.json"); +const cliPackageJsonPath = path.join(packageRoot, "package.json"); const defaultOutDir = path.join(packageRoot, "dist-static"); const fuse = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"; @@ -89,6 +92,27 @@ async function run(command, args, options = {}) { if (stderr) process.stderr.write(stderr); } +async function readPackageVersion(packageJsonPath) { + try { + const raw = await fs.readFile(packageJsonPath, "utf8"); + const parsed = JSON.parse(raw); + return typeof parsed.version === "string" ? parsed.version.trim() : ""; + } catch { + return ""; + } +} + +async function resolveRuntimeVersion() { + const explicit = process.env.ADE_CLI_VERSION?.trim(); + if (explicit) return explicit; + + const cliVersion = await readPackageVersion(cliPackageJsonPath); + if (cliVersion && cliVersion !== "0.0.0") return cliVersion; + + const desktopVersion = await readPackageVersion(desktopPackageJsonPath); + return desktopVersion || cliVersion || "0.0.0"; +} + async function assertSeaCapableNodeBinary(binaryPath) { const contents = await fs.readFile(binaryPath); if (contents.includes(Buffer.from(fuse))) return; @@ -112,6 +136,21 @@ async function adHocSignIfNeeded(binaryPath) { await run("codesign", ["--sign", "-", binaryPath]); } +async function assertStaticRuntimeVersion(binaryPath, expectedVersion, target) { + if (target !== currentTarget()) return; + const { stdout } = await execFileAsync(binaryPath, ["--version"], { + cwd: packageRoot, + env: { + ...process.env, + ADE_CLI_VERSION: "", + }, + }); + const actual = stdout.trim().replace(/^ade\s+/i, ""); + if (actual !== expectedVersion) { + throw new Error(`Static ADE runtime version mismatch: expected ${expectedVersion}, got ${actual || ""}.`); + } +} + async function writeSeaEntry(workDir) { const cliPath = path.join(packageRoot, "dist", "cli.cjs"); const seaEntryPath = path.join(workDir, "cli-sea.cjs"); @@ -221,6 +260,8 @@ async function main() { const args = parseArgs(process.argv.slice(2)); await assertHostOrExplicitBinary(args.target); await fs.mkdir(args.outDir, { recursive: true }); + const runtimeVersion = await resolveRuntimeVersion(); + process.env.ADE_CLI_VERSION = runtimeVersion; if (!args.skipBuild) { await run(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build"]); @@ -263,6 +304,7 @@ async function main() { } await run(path.join(packageRoot, "node_modules", ".bin", process.platform === "win32" ? "postject.cmd" : "postject"), postjectArgs); await adHocSignIfNeeded(binaryPath); + await assertStaticRuntimeVersion(binaryPath, runtimeVersion, args.target); let nativeArchivePath = null; if (!args.skipNativeDeps) { diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index ce9bc58a1..2a355da65 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -1442,6 +1442,22 @@ describe("ptyService", () => { ); }); + it("uses a default title when runtime payloads omit one", async () => { + const { service, sessionService } = createHarness(); + await service.create({ + laneId: "lane-1", + cols: 120, + rows: 40, + } as any); + expect(sessionService.create).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + title: "Terminal", + tracked: true, + }), + ); + }); + it("rejects terminal launches when the lane worktree does not exist", async () => { mocks.existsSyncResults.set("/tmp/test-worktree", false); const { service, loadPty } = createHarness(); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index cd621ec6d..5a5bc0afd 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -536,6 +536,11 @@ function isCliPlaceholderTitle(title: string | null | undefined, toolType: Termi return false; } +function normalizePtySessionTitle(title: unknown): string { + const trimmed = typeof title === "string" ? title.trim() : ""; + return trimmed.length ? trimmed : "Terminal"; +} + function sanitizeGeneratedCliTitle(raw: string): string { let title = stripAnsi(raw) .replace(/\p{Extended_Pictographic}/gu, "") @@ -3398,7 +3403,8 @@ export function createPtyService({ }, async create(args: PtyCreateArgs): Promise { - const { laneId, title } = args; + const { laneId } = args; + const title = normalizePtySessionTitle(args.title); const chatSessionId = cleanOptionalId(args.chatSessionId); const launchContext = resolveLaneLaunchContext({ laneService, From 6db127540a241063b3d89e7145ca06b507b65078 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:07:36 -0400 Subject: [PATCH 55/80] fix(remote): prefer tailscale ip discovery routes --- .../main/services/remoteRuntime/runtimeDiscovery.test.ts | 6 +++--- .../src/main/services/remoteRuntime/runtimeDiscovery.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts index 4618acf9e..f650e0e49 100644 --- a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts @@ -38,7 +38,7 @@ describe("runtimeDiscovery", () => { port: 8787, addresses: ["192.168.1.42", "100.75.20.63", "127.0.0.1"], primaryRoute: "192.168.1.42", - tailscaleAddress: "studio.tailnet.ts.net", + tailscaleAddress: "100.75.20.63", runtimeKind: "daemon", runtimeVersion: "0.0.0", projectIds: ["project-a", "project-b"], @@ -104,8 +104,8 @@ describe("runtimeDiscovery", () => { hostName: "aruls-mac-studio", port: 22, addresses: ["100.75.20.63", "aruls-mac-studio.tail7497a6.ts.net"], - primaryRoute: "aruls-mac-studio.tail7497a6.ts.net", - tailscaleAddress: "aruls-mac-studio.tail7497a6.ts.net", + primaryRoute: "100.75.20.63", + tailscaleAddress: "100.75.20.63", runtimeKind: "tailscale-peer", runtimeVersion: null, projectIds: [], diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts index cc593eb36..b93dae159 100644 --- a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts @@ -152,7 +152,7 @@ export function discoveredRuntimeFromBonjourService( const port = servicePort ?? 8787; const announcedAddresses = splitCsv(txt.addresses); const tailscaleAddress = firstNonEmpty( - [txt.tailscaleDnsName, txt.tailscaleIp].filter((value): value is string => + [txt.tailscaleIp, txt.tailscaleDnsName].filter((value): value is string => Boolean(value && isTailscaleRoute(value)), ), ); @@ -218,7 +218,7 @@ export function discoveredRuntimesFromTailscaleStatus( ) : []; const dnsName = normalizeTailscaleDnsName(peer.DNSName); - const tailscaleAddress = firstNonEmpty([dnsName, tailscaleIps[0]]); + const tailscaleAddress = firstNonEmpty([tailscaleIps[0], dnsName]); if (!tailscaleAddress) continue; const hostName = trimmed(peer.HostName) ?? dnsName; From d0bba1b7ed917ec1f0bee4c52155963299c7d5f7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:21:41 -0400 Subject: [PATCH 56/80] fix(remote): bound ssh exec channels --- .../remoteRuntime/sshTransport.test.ts | 36 +++++++++++ .../services/remoteRuntime/sshTransport.ts | 64 +++++++++++++++++-- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts index 1e7f59c6b..7bf422443 100644 --- a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts @@ -3,6 +3,8 @@ import { createHmac } from "node:crypto"; import net from "node:net"; import os from "node:os"; import path from "node:path"; +import { PassThrough } from "node:stream"; +import type { Client } from "ssh2"; import { afterEach, describe, expect, it } from "vitest"; import type { RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; import { @@ -10,6 +12,7 @@ import { buildSshConfigCandidates, buildSshRouteCandidates, buildSshUsernameCandidates, + execSsh, getSshHostKeyTrustForTarget, hasKnownSshHostKeyForTarget, parseOpenSshHostConfig, @@ -562,6 +565,39 @@ describe("buildSshConfig", () => { }); }); +describe("execSsh", () => { + it("times out and closes a remote exec channel that never finishes", async () => { + let closed = false; + let destroyed = false; + const stream = new PassThrough() as PassThrough & { + stderr: PassThrough; + close: () => void; + destroy: () => PassThrough; + }; + stream.stderr = new PassThrough(); + stream.close = () => { + closed = true; + stream.emit("close"); + }; + const originalDestroy = stream.destroy.bind(stream); + stream.destroy = () => { + destroyed = true; + originalDestroy(); + return stream; + }; + const client = { + exec(_command: string, callback: (error: Error | null, channel: typeof stream) => void) { + callback(null, stream); + }, + } as unknown as Client; + + await expect(execSsh(client, "sleep 1000", { timeoutMs: 75 })).rejects.toThrow( + "Timed out waiting for SSH command to finish after 75ms.", + ); + expect(closed || destroyed).toBe(true); + }); +}); + describe("parseOpenSshHostConfig", () => { it("keeps the first matching value and supports wildcard blocks", () => { expect(parseOpenSshHostConfig([ diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts index befa936c9..7610d6db3 100644 --- a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts @@ -20,6 +20,13 @@ export type SshExecResult = { }; const MAX_SSH_EXEC_OUTPUT_BYTES = 8 * 1024 * 1024; +const DEFAULT_SSH_EXEC_TIMEOUT_MS = 30_000; +const MIN_SSH_EXEC_TIMEOUT_MS = 50; + +type ExecSshOptions = { + timeoutMs?: number; + env?: NodeJS.ProcessEnv; +}; type OpenSshHostConfig = { hostName?: string; @@ -584,6 +591,14 @@ function sshConnectAttemptTimeoutMs(env: NodeJS.ProcessEnv): number { : DEFAULT_SSH_CONNECT_ATTEMPT_TIMEOUT_MS; } +function sshExecTimeoutMs(options: ExecSshOptions = {}): number { + const raw = options.timeoutMs ?? options.env?.ADE_REMOTE_SSH_EXEC_TIMEOUT_MS ?? process.env.ADE_REMOTE_SSH_EXEC_TIMEOUT_MS; + const parsed = raw == null ? NaN : typeof raw === "number" ? raw : Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= MIN_SSH_EXEC_TIMEOUT_MS + ? parsed + : DEFAULT_SSH_EXEC_TIMEOUT_MS; +} + function uniqueUsernames(values: Array): string[] { const seen = new Set(); const result: string[] = []; @@ -926,41 +941,76 @@ export async function connectSsh(target: RemoteRuntimeTarget): Promise { return (await connectSshWithRoute(target)).client; } -export function execSsh(client: Client, command: string): Promise { +export function execSsh(client: Client, command: string, options: ExecSshOptions = {}): Promise { return new Promise((resolve, reject) => { client.exec(command, (error, stream) => { if (error) { reject(error); return; } + let settled = false; let stdout = ""; let stderr = ""; let stdoutBytes = 0; let stderrBytes = 0; let code: number | null = null; + let timeout: NodeJS.Timeout | null = null; + const closeStream = (): void => { + try { + stream.close(); + } catch {} + try { + (stream as unknown as { destroy?: () => void }).destroy?.(); + } catch {} + }; + const clearTimer = (): void => { + if (!timeout) return; + clearTimeout(timeout); + timeout = null; + }; + const settle = (callback: () => void): void => { + if (settled) return; + settled = true; + clearTimer(); + callback(); + }; + const fail = (failure: Error): void => { + settle(() => { + closeStream(); + reject(failure); + }); + }; + const timeoutMs = sshExecTimeoutMs(options); + timeout = setTimeout(() => { + fail(new Error(`Timed out waiting for SSH command to finish after ${timeoutMs}ms.`)); + }, timeoutMs); + timeout.unref?.(); stream.on("data", (chunk: Buffer) => { + if (settled) return; stdoutBytes += chunk.byteLength; if (stdoutBytes > MAX_SSH_EXEC_OUTPUT_BYTES) { - reject(new Error(`SSH command stdout exceeded ${MAX_SSH_EXEC_OUTPUT_BYTES} bytes.`)); - stream.close(); + fail(new Error(`SSH command stdout exceeded ${MAX_SSH_EXEC_OUTPUT_BYTES} bytes.`)); return; } stdout += chunk.toString("utf8"); }); stream.stderr.on("data", (chunk: Buffer) => { + if (settled) return; stderrBytes += chunk.byteLength; if (stderrBytes > MAX_SSH_EXEC_OUTPUT_BYTES) { - reject(new Error(`SSH command stderr exceeded ${MAX_SSH_EXEC_OUTPUT_BYTES} bytes.`)); - stream.close(); + fail(new Error(`SSH command stderr exceeded ${MAX_SSH_EXEC_OUTPUT_BYTES} bytes.`)); return; } stderr += chunk.toString("utf8"); }); stream.on("exit", (exitCode: number | null) => { + if (settled) return; code = exitCode; }); - stream.on("close", () => resolve({ stdout, stderr, code })); - stream.on("error", reject); + stream.on("close", () => { + settle(() => resolve({ stdout, stderr, code })); + }); + stream.on("error", (streamError: Error) => fail(streamError)); }); }); } From 363a97055c314c9eefbdf1c0abbc4dd46be3c569 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:24:00 -0400 Subject: [PATCH 57/80] perf(remote): cache runtime artifact hashes --- .../remoteRuntime/remoteBootstrap.test.ts | 49 +++++++++++++++++++ .../services/remoteRuntime/remoteBootstrap.ts | 28 ++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 91d01f692..0acee1ae1 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -763,6 +763,55 @@ describe("bootstrapRemoteRuntime upload flow", () => { }); }); + it("reuses cached local runtime hashes across repeated bootstraps", async () => { + const resources = createTempResources("linux-x64"); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + }); + execSshMock.mockImplementation(async (_client: Client, command: string) => { + const remotePath = resolvedRemotePath(command); + if (remotePath) return remotePath; + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(`${APP_VERSION}\n`); + if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(`${resources.binarySha256}\n`); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(`ade ${APP_VERSION}\n`); + if ( + command.includes("wc -c < $HOME/.ade/bin/ade") && + command.includes("shasum -a 256 $HOME/.ade/bin/ade") && + command.includes("echo ok") + ) return ok("ok\n"); + if (command === "command -v node || true") return ok("\n"); + return defaultRemoteBootstrapCommand(command); + }); + const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); + let binaryReadCount = 0; + try { + await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + binaryReadCount = readFileSyncSpy.mock.calls.filter( + ([filePath]) => filePath === resources.binaryPath, + ).length; + } finally { + readFileSyncSpy.mockRestore(); + } + + expect(binaryReadCount).toBe(1); + }); + it("uploads a same-version runtime when the bundled binary hash changed", async () => { const resources = createTempResources(); cleanupResources = resources.cleanup; diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index 01ee18012..df4d2e77d 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -319,8 +319,34 @@ function bundledPtyHostWorkerPath(resourcesPath: string, localBinaryPath: string }) ?? null; } +type LocalArtifactHashCacheEntry = { + size: number; + mtimeMs: number; + ctimeMs: number; + sha256: string; +}; + +const localArtifactHashCache = new Map(); + function hashRuntimeBinary(localPath: string): string { - return crypto.createHash("sha256").update(fs.readFileSync(localPath)).digest("hex"); + const stat = fs.statSync(localPath); + const cached = localArtifactHashCache.get(localPath); + if ( + cached && + cached.size === stat.size && + cached.mtimeMs === stat.mtimeMs && + cached.ctimeMs === stat.ctimeMs + ) { + return cached.sha256; + } + const sha256 = crypto.createHash("sha256").update(fs.readFileSync(localPath)).digest("hex"); + localArtifactHashCache.set(localPath, { + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + sha256, + }); + return sha256; } function fileSizeBytes(localPath: string): number { From 67404dc51eb6b1b62118b277362c94b2aa8a8b49 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:45:41 -0400 Subject: [PATCH 58/80] fix(remote): unwrap chat list session args --- .../main/services/adeActions/registry.test.ts | 20 +++++++++++++++++++ .../src/main/services/adeActions/registry.ts | 15 ++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 652fb388d..6be0fc197 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -348,6 +348,26 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(getModelCatalog).toHaveBeenCalledWith({ mode: "cached" }); }); + it("unwraps chat.listSessions action args before calling the positional chat service API", async () => { + const listSessions = vi.fn(async () => []); + const runtime = { + agentChatService: { + listSessions, + }, + } as unknown as Parameters[0]; + + const chat = getAdeActionDomainServices(runtime).chat as { + listSessions?: (args?: unknown) => Promise; + }; + + await expect(chat.listSessions?.({ + laneId: " lane-1 ", + includeAutomation: true, + })).resolves.toEqual([]); + expect(listAllowedAdeActionNames("chat", chat as Record)).toContain("listSessions"); + expect(listSessions).toHaveBeenCalledWith("lane-1", { includeAutomation: true }); + }); + it("exposes the browser panel and tab control surface", () => { const actions = ADE_ACTION_ALLOWLIST.built_in_browser ?? []; for (const name of ["claim", "startSession", "listSessions", "endSession", "showPanel", "navigate", "createTab", "switchTab", "closeTab", "observe", "getTrace", "click", "typeText", "dispatchKey", "scroll", "fill", "clear", "wait"]) { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index afffd587e..e4fcc34d9 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -1023,6 +1023,21 @@ function buildChatDomainService(runtime: AdeRuntime): OpaqueService | null { "Terminal service not available.", ), }), + listSessions: (args?: unknown) => { + const record = asActionRecord(args); + const laneId = typeof record.laneId === "string" && record.laneId.trim() + ? record.laneId.trim() + : undefined; + const options = { + ...(typeof record.includeArchived === "boolean" ? { includeArchived: record.includeArchived } : {}), + ...(typeof record.includeAutomation === "boolean" ? { includeAutomation: record.includeAutomation } : {}), + ...(typeof record.includeIdentity === "boolean" ? { includeIdentity: record.includeIdentity } : {}), + }; + return agentChatService.listSessions( + laneId, + Object.keys(options).length ? options : undefined, + ); + }, modelCatalog: (args?: unknown) => agentChatService.getModelCatalog(args && typeof args === "object" ? args as never : undefined), codexOpenInCli: async ( From 511bbb2952da4418c62edb7549a4d03a92d1d770 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:45:48 -0400 Subject: [PATCH 59/80] test(remote): use generic fixture hosts --- .../remoteTargetRegistry.test.ts | 12 +++---- .../remoteRuntime/runtimeDiscovery.test.ts | 34 +++++++++---------- .../remoteRuntime/sshTransport.test.ts | 16 ++++----- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts index b7f376c69..8671fe117 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts @@ -18,8 +18,8 @@ describe("RemoteTargetRegistry", () => { const registry = new RemoteTargetRegistry(); const target = registry.save({ - name: "Mac Studio", - hostname: "100.75.20.63", + name: "Build Server", + hostname: "203.0.113.10", sshUser: "admin", port: null, sshKeyPath: null, @@ -38,14 +38,14 @@ describe("RemoteTargetRegistry", () => { const registry = new RemoteTargetRegistry(); const target = registry.save({ - name: "Mac Studio", - hostname: "studio.tailnet.ts.net", + name: "Build Server", + hostname: "studio.tailnet.example", sshUser: "admin", port: null, sshKeyPath: null, routes: [ { - hostname: "studio.tailnet.ts.net", + hostname: "studio.tailnet.example", port: null, source: "tailscale", lastSucceededAt: null, @@ -61,7 +61,7 @@ describe("RemoteTargetRegistry", () => { expect(target.routes).toEqual([ { - hostname: "studio.tailnet.ts.net", + hostname: "studio.tailnet.example", port: null, source: "tailscale", lastSucceededAt: null, diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts index f650e0e49..02a61efb2 100644 --- a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts @@ -21,9 +21,9 @@ describe("runtimeDiscovery", () => { projects: "project-a, project-b", projectCount: "2", host: "192.168.1.42", - addresses: "127.0.0.1,100.75.20.63", - tailscaleDnsName: "studio.tailnet.ts.net", - tailscaleIp: "100.75.20.63", + addresses: "127.0.0.1,100.64.0.10", + tailscaleDnsName: "studio.tail000000.ts.net", + tailscaleIp: "100.64.0.10", }, }, 1234, @@ -36,9 +36,9 @@ describe("runtimeDiscovery", () => { hostIdentity: "device-123", hostName: "studio.local", port: 8787, - addresses: ["192.168.1.42", "100.75.20.63", "127.0.0.1"], + addresses: ["192.168.1.42", "100.64.0.10", "127.0.0.1"], primaryRoute: "192.168.1.42", - tailscaleAddress: "100.75.20.63", + tailscaleAddress: "100.64.0.10", runtimeKind: "daemon", runtimeVersion: "0.0.0", projectIds: ["project-a", "project-b"], @@ -84,10 +84,10 @@ describe("runtimeDiscovery", () => { Peer: { "nodekey:abc": { ID: "peer-1", - HostName: "aruls-mac-studio", - DNSName: "aruls-mac-studio.tail7497a6.ts.net.", + HostName: "build-studio", + DNSName: "build-studio.tail000000.ts.net.", OS: "macOS", - TailscaleIPs: ["100.75.20.63", "fd7a:115c:a1e0::1"], + TailscaleIPs: ["100.64.0.10", "fd7a:115c:a1e0::1"], Online: true, }, }, @@ -99,13 +99,13 @@ describe("runtimeDiscovery", () => { expect(discovered[0]).toMatchObject({ id: "tailscale:peer-1", serviceName: "Tailscale peer", - machineName: "aruls-mac-studio", + machineName: "build-studio", hostIdentity: "peer-1", - hostName: "aruls-mac-studio", + hostName: "build-studio", port: 22, - addresses: ["100.75.20.63", "aruls-mac-studio.tail7497a6.ts.net"], - primaryRoute: "100.75.20.63", - tailscaleAddress: "100.75.20.63", + addresses: ["100.64.0.10", "build-studio.tail000000.ts.net"], + primaryRoute: "100.64.0.10", + tailscaleAddress: "100.64.0.10", runtimeKind: "tailscale-peer", runtimeVersion: null, projectIds: [], @@ -121,17 +121,17 @@ describe("runtimeDiscovery", () => { "nodekey:iphone": { ID: "peer-phone", HostName: "iPhone", - DNSName: "iphone.tail7497a6.ts.net.", + DNSName: "iphone.tail000000.ts.net.", OS: "iOS", - TailscaleIPs: ["100.75.20.64"], + TailscaleIPs: ["100.64.0.11"], Online: true, }, "nodekey:mac": { ID: "peer-mac", HostName: "studio", - DNSName: "studio.tail7497a6.ts.net.", + DNSName: "studio.tail000000.ts.net.", OS: "macOS", - TailscaleIPs: ["100.75.20.63"], + TailscaleIPs: ["100.64.0.10"], Online: true, }, }, diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts index 7bf422443..fed97abba 100644 --- a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts @@ -145,7 +145,7 @@ describe("buildSshConfig", () => { it("builds an admin retry candidate when no SSH user is configured", () => { expect(buildSshUsernameCandidates({ ...target, - hostname: "100.75.20.63", + hostname: "203.0.113.10", sshUser: null, port: null, }, { @@ -174,7 +174,7 @@ describe("buildSshConfig", () => { it("builds retry configs with distinct SSH usernames", () => { const configs = buildSshConfigCandidates({ ...target, - hostname: "100.75.20.63", + hostname: "203.0.113.10", sshUser: null, port: null, }, { @@ -183,13 +183,13 @@ describe("buildSshConfig", () => { }); expect(configs.map((config) => config.username)).toEqual(Array.from(new Set([os.userInfo().username, "admin"]))); - expect(configs.every((config) => config.host === "100.75.20.63" && config.port === 22)).toBe(true); + expect(configs.every((config) => config.host === "203.0.113.10" && config.port === 22)).toBe(true); }); it("tries saved route fallbacks and prioritizes the last successful route", () => { const configs = buildSshConfigCandidates({ ...target, - hostname: "studio.tailnet.ts.net", + hostname: "studio.tailnet.example", sshUser: null, port: null, routes: [ @@ -200,7 +200,7 @@ describe("buildSshConfig", () => { lastSucceededAt: 200, }, { - hostname: "studio.tailnet.ts.net", + hostname: "studio.tailnet.example", port: null, source: "tailscale", lastSucceededAt: 100, @@ -214,11 +214,11 @@ describe("buildSshConfig", () => { const usernames = Array.from(new Set([os.userInfo().username, "admin"])); expect(configs.map((config) => config.host)).toEqual([ ...usernames.map(() => "192.168.1.42"), - ...usernames.map(() => "studio.tailnet.ts.net"), + ...usernames.map(() => "studio.tailnet.example"), ]); expect(buildSshRouteCandidates({ ...target, - hostname: "studio.tailnet.ts.net", + hostname: "studio.tailnet.example", routes: [ { hostname: "192.168.1.42", @@ -229,7 +229,7 @@ describe("buildSshConfig", () => { ], }).map((route) => route.hostname)).toEqual([ "192.168.1.42", - "studio.tailnet.ts.net", + "studio.tailnet.example", ]); }); From 172db16f573f1defaf56c13ad829682d6d1c5cb1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:54:13 -0400 Subject: [PATCH 60/80] fix(work): keep local-only tools hidden remotely --- .../terminals/TerminalsPage.test.tsx | 44 ++++++++++++++++++- .../components/terminals/TerminalsPage.tsx | 4 +- .../components/terminals/WorkViewArea.tsx | 5 ++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx index 09cfc00a9..d8a326fbd 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx @@ -148,6 +148,10 @@ const workMocks = vi.hoisted(() => { baseWork, currentWork: baseWork as any, projectRoot: null as string | null, + projectBinding: null as null | { + kind: "remote"; + rootPath: string; + }, fns, makeTerminalSession, }; @@ -181,9 +185,14 @@ vi.mock("../../state/appStore", () => ({ if (state.projectBinding?.kind === "remote") return state.projectBinding.rootPath?.trim() || null; return state.project?.rootPath?.trim() || null; }, - useAppStore: (selector: (state: { selectedLaneId: string; project: { rootPath: string } | null }) => T): T => + useAppStore: (selector: (state: { + selectedLaneId: string; + project: { rootPath: string } | null; + projectBinding: typeof workMocks.projectBinding; + }) => T): T => selector({ selectedLaneId: "lane-primary", + projectBinding: workMocks.projectBinding, project: workMocks.projectRoot ? { rootPath: workMocks.projectRoot } : null, @@ -280,6 +289,7 @@ describe("TerminalsPage chat session activation", () => { cleanup(); workMocks.currentWork = { ...workMocks.baseWork, closingPtyIds: new Set() }; workMocks.projectRoot = null; + workMocks.projectBinding = null; sidebarProps.latest = null; sessionListPaneProps.latest = null; vi.clearAllMocks(); @@ -358,6 +368,38 @@ describe("TerminalsPage chat session activation", () => { expect(workMocks.currentWork.setWorkSidebarTab).toHaveBeenCalledWith("browser"); }); + it("ignores Browser sidebar open requests for remote projects", async () => { + workMocks.projectRoot = "/repo-one"; + workMocks.projectBinding = { + kind: "remote", + rootPath: "/repo-one", + }; + const browserEventListener: { + current: ((event: { type?: string; status?: unknown }) => void) | null; + } = { current: null }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { + builtInBrowser: { + onEvent: vi.fn((listener) => { + browserEventListener.current = listener; + return vi.fn(); + }), + }, + }, + }); + + render(); + + await waitFor(() => expect(browserEventListener.current).not.toBeNull()); + browserEventListener.current?.({ + type: "open-request", + status: { profileProjectRoot: "/repo-one" }, + }); + expect(workMocks.currentWork.setViewMode).not.toHaveBeenCalled(); + expect(workMocks.currentWork.setWorkSidebarTab).not.toHaveBeenCalled(); + }); + it("targets the visible Work draft when no saved session is active", async () => { Object.defineProperty(window, "ade", { configurable: true, diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index c90c1493e..8403ec94b 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -551,9 +551,11 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { const workSidebarVisible = active && work.workSidebarOpen && work.viewMode !== "grid"; const { setViewMode, setWorkSidebarTab, showDraftKind } = work; + const isRemoteProject = useAppStore((state) => state.projectBinding?.kind === "remote"); useEffect(() => { if (!active) return; const openBrowserSidebar = () => { + if (isRemoteProject) return; setViewMode("tabs"); setWorkSidebarTab("browser"); }; @@ -578,7 +580,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { window.removeEventListener("ade:work:stop-orchestrator-chat", stopOrchestratorChat); unsubscribeBrowserEvents?.(); }; - }, [active, projectRoot, setViewMode, setWorkSidebarTab, showDraftKind]); + }, [active, isRemoteProject, projectRoot, setViewMode, setWorkSidebarTab, showDraftKind]); const toggleSessionsPane = useCallback(() => { work.setWorkFocusSessionsHidden(!work.workFocusSessionsHidden); diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index b194df093..8b0355b17 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -1123,12 +1123,15 @@ function WorkSidebarToggle({ open: boolean; onToggle?: () => void; }) { + const isRemoteProject = useAppStore((state) => state.projectBinding?.kind === "remote"); if (!onToggle) return null; return ( + +
+
+ + {manualAddOpen ? ( +
+
+ Add machine +
+ +
+ ) : null} + + {discoveryError ? ( +
+ + {discoveryError} +
+ ) : null} + + {loading ? ( +
+ Loading machines... +
+ ) : null} + +
+ {targets.map((target) => { + const targetStatus = + connectionSnapshot?.connections.find( + (entry) => entry.target.id === target.id, + ) ?? null; + const targetConnected = targetStatus + ? targetStatus.state === "connected" + : connected?.target.id === target.id; + const targetConnecting = + busyId === target.id || targetStatus?.state === "connecting"; + const targetSelected = selectedId === target.id; + const targetError = targetSelected + ? (error ?? selectedConnectionError) + : targetStatus?.lastError + ? formatRemoteTargetError(targetStatus.lastError) + : null; + const targetWarnings = targetSelected + ? selectedCompatibilityWarnings + : targetStatus?.compatibilityWarnings ?? []; + const version = + targetStatus?.version ?? + (connected?.target.id === target.id ? connected.version : null) ?? + target.runtimeBinaryVersion ?? + null; + const arch = + targetStatus?.arch ?? + (connected?.target.id === target.id ? connected.arch : null) ?? + target.lastSeenArch ?? + null; + const statusLabel = connectionStateLabel( + targetStatus, + connected?.target.id === target.id ? connected : null, + ); + const formOpen = formPrefill?.targetId === target.id; + + return ( +
- Connect over SSH -
-
- -
- {loading ? ( -
- Loading machines... -
- ) : targets.length === 0 ? ( -
- No remote machines saved yet. -
- ) : ( -
- {targets.map((target) => { - const active = selectedId === target.id; - const targetStatus = - connectionSnapshot?.connections.find( - (entry) => entry.target.id === target.id, - ) ?? null; - const isConnected = targetStatus - ? targetStatus.state === "connected" - : connected?.target.id === target.id; - return ( - + ) : ( + + )} + - ); - })} -
- )} - + Edit + + + + -
-
-
-
- NEARBY MACHINES -
-
- LAN and Tailscale discovery -
-
- -
- {discoveryError ? ( -
- - {discoveryError} -
- ) : null} - {loadingDiscovered ? ( -
- Scanning nearby machines... -
- ) : visibleDiscoveredMachines.length === 0 ? ( -
- {discoveredMachines.length > 0 - ? "Nearby machines are already saved above." - : "No LAN ADE services or Tailscale peers found."} -
- ) : ( -
- {visibleDiscoveredMachines.map((machine) => { - const route = discoveredRoute(machine); - return ( + {targetError ? ( +
+ + {targetError} +
+ ) : null} + + {targetWarnings.length > 0 ? (
+ {targetWarnings.map((warning) => ( +
+ + {warning} +
+ ))} +
+ ) : null} + + {targetSelected && selectedHostKeyTrust ? ( +
-
-
- {machine.machineName} -
-
- {route - ? `${route}:${machine.port}` - : "No route advertised"} -
-
- + + {selectedHostKeyTrust.state === "changed" + ? "Machine identity changed" + : "Trust this machine"} +
+
+ {selectedHostKeyTrust.state === "changed" + ? `ADE found a different SSH identity for ${selectedHostKeyTrust.host}:${selectedHostKeyTrust.port}. Review ${selectedHostKeyTrust.knownHostsPath ?? "known_hosts"} before connecting.` + : `ADE found a new SSH identity for ${selectedHostKeyTrust.host}:${selectedHostKeyTrust.port}. Trust it once to connect.`}
- {discoveredRuntimeLabel(machine)} |{" "} - {discoveredProjectLabel(machine)} + {selectedHostKeyTrust.fingerprintSha256}
+ {selectedHostKeyTrust.state === "needs_trust" ? ( +
+ + +
+ ) : null}
- ); - })} -
- )} -
- - -
-
-
- {editingSavedTarget ? "EDIT MACHINE" : "ADD MACHINE"} -
-
- {editingSavedTarget ? editingSavedTarget.name : "SSH target"} -
-
- -
- - -
-
-
-
- CONNECTION -
-
- {selectedTarget ? selectedTarget.name : "Select a machine"} -
-
- {selectedTarget ? ( -
- - -
- ) : null} -
+ ) : null} +
- {selectedHostKeyTrust ? ( -
-
- - {selectedHostKeyTrust.state === "changed" - ? "Machine identity changed" - : "Trust this machine"} -
-
- {selectedHostKeyTrust.state === "changed" - ? `ADE found a different SSH identity for ${selectedHostKeyTrust.host}:${selectedHostKeyTrust.port}. Review ${selectedHostKeyTrust.knownHostsPath ?? "known_hosts"} before connecting.` - : `ADE found a new SSH identity for ${selectedHostKeyTrust.host}:${selectedHostKeyTrust.port}. Trust it once to connect over Wi-Fi or Tailscale.`} -
-
- {selectedHostKeyTrust.fingerprintSha256} -
- {selectedHostKeyTrust.state === "needs_trust" ? ( -
- - + {formOpen ? ( +
+
+ Edit {target.name} +
+ +
+ ) : null}
- ) : null} -
- ) : null} - - {error || selectedConnectionError ? ( -
- - {error ?? selectedConnectionError} -
- ) : null} + ); + })} - {selectedTarget ? ( -
-
-
-
- {selectedConnectionLabel} -
-
- {targetConnectionLabel(selectedTarget)} -
-
- {selectedConnection?.state === "connected" || - (!selectedConnection && - connected?.target.id === selectedTarget.id) ? ( - - ) : null} -
- {selectedConnection?.state === "connected" || - (!selectedConnection && - connected?.target.id === selectedTarget.id) ? ( - <> -
- ADE service{" "} - {selectedConnection?.version ?? connected?.version ?? "unknown"}{" "} - on {selectedConnection?.arch ?? connected?.arch ?? "unknown"}. -
- {selectedCompatibilityWarnings.length > 0 ? ( + {visibleDiscoveredMachines.map((machine) => { + const route = discoveredRoute(machine); + const formOpen = activeFormKey === `${machine.id}:${machine.lastSeenAt}`; + return ( +
+
- {selectedCompatibilityWarnings.map((warning) => ( -
- - {warning} +
+
+ {machine.machineName}
- ))} +
+ {route ? `${route}:${machine.port}` : "No route advertised"} +
+
+ Detected · {discoveredRuntimeLabel(machine)} ·{" "} + {discoveredProjectLabel(machine)} +
+
+ +
+
+ {formOpen ? ( +
+
+ Add {machine.machineName} +
+
) : null} - - ) : ( -
- Remote projects are opened from Add Project after this machine - is connected.
- )} + ); + })} +
+ + {!loading && targets.length === 0 && !manualAddOpen && !loadingDiscovered && visibleDiscoveredMachines.length === 0 ? ( +
+ {discoveredMachines.length > 0 + ? "Nearby machines are already saved." + : "No saved or detected machines yet."}
- ) : ( -
- Save a machine to keep ADE connected in the background. + ) : null} + {loadingDiscovered ? ( +
+ Scanning nearby machines...
- )} + ) : null} + {!loadingDiscovered && targets.length > 0 && visibleDiscoveredMachines.length === 0 && discoveredMachines.length > 0 ? ( +
Nearby machines are already saved.
+ ) : null}
); From 657918a65a31b84d0f3395ebf21aa904a1e3eb01 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:03:51 -0400 Subject: [PATCH 65/80] fix(remote): make machine edit details collapsible --- .../remoteTargets/RemoteTargetList.test.tsx | 42 +++++++++++++++++++ .../remoteTargets/RemoteTargetList.tsx | 22 ++++++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx index 430bf8367..06832b522 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx @@ -210,6 +210,48 @@ describe("RemoteTargetList", () => { expect(screen.getByText("Not connected")).toBeTruthy(); }); + it("toggles the saved machine edit details from the Edit button", async () => { + const target = { + id: "target-1", + name: "Mac Studio", + hostname: "100.75.20.63", + sshUser: "admin", + port: 22, + sshKeyPath: "/Users/arul/.ssh/id_ed25519", + lastSeenArch: "darwin-arm64", + runtimeBinaryVersion: "1.0.0", + lastConnectedAt: null, + }; + remoteRuntimeMock.listTargets.mockResolvedValue([target]); + remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ + machines: [], + diagnostics: [], + }); + installAdeMock(); + + render(); + + await waitFor(() => + expect(screen.getAllByText("Mac Studio").length).toBeGreaterThan(0), + ); + const editButton = screen.getByRole("button", { name: "Edit" }); + expect(editButton.getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByText("Edit Mac Studio")).toBeNull(); + + fireEvent.click(editButton); + + expect(editButton.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByText("Edit Mac Studio")).toBeTruthy(); + expect((screen.getByLabelText("Host") as HTMLInputElement).value).toBe( + "100.75.20.63", + ); + + fireEvent.click(editButton); + + expect(editButton.getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByText("Edit Mac Studio")).toBeNull(); + }); + it("shows an actionable message when the SSH host-key probe is reset", async () => { const target = { id: "target-1", diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index 7a539f98e..8d61b0931 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -6,6 +6,8 @@ import { type CSSProperties, } from "react"; import { + CaretDown, + CaretUp, CheckCircle, DesktopTower, PlugsConnected, @@ -497,11 +499,13 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { setHostKeyTrust(null); }, []); - const openEditForm = useCallback((target: RemoteRuntimeTarget) => { + const toggleEditForm = useCallback((target: RemoteRuntimeTarget) => { setSelectedId(target.id); - setFormPrefill(targetFormPrefill(target)); setError(null); setHostKeyTrust(null); + setFormPrefill((current) => + current?.targetId === target.id ? null : targetFormPrefill(target), + ); }, []); const ensureHostKeyTrust = useCallback(async (targetId: string) => { @@ -949,8 +953,10 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { )}
{formOpen ? ( -
+
- Add {machine.machineName} + Edit {machine.machineName}
From 6c2217ba2fcb88d3c60707661e746259d1dbb0f7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:18:08 -0400 Subject: [PATCH 67/80] fix(remote): keep direct connect for detected machines --- .../remoteTargets/RemoteTargetList.test.tsx | 7 +- .../remoteTargets/RemoteTargetList.tsx | 152 ++++++++++++------ 2 files changed, 110 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx index 21fca2be3..f731e6ca8 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx @@ -105,8 +105,6 @@ describe("RemoteTargetList", () => { expect(editButton.getAttribute("aria-expanded")).toBe("false"); expect(screen.queryByText("Edit Studio")).toBeNull(); - fireEvent.click(editButton); - const savedTarget = { id: "target-1", name: "Studio", @@ -146,7 +144,7 @@ describe("RemoteTargetList", () => { projects: [], }); - fireEvent.click(screen.getByRole("button", { name: "Save and connect" })); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); await waitFor(() => expect(remoteRuntimeMock.saveTarget).toHaveBeenCalledWith( @@ -156,6 +154,9 @@ describe("RemoteTargetList", () => { }), ), ); + await waitFor(() => + expect(remoteRuntimeMock.connect).toHaveBeenCalledWith("target-1"), + ); }); it("connects a saved machine without listing remote projects in the connection manager", async () => { diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index cb48acd20..d7564e6ff 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -205,6 +205,21 @@ function discoveredSshRoutes( return routes; } +function discoveredTargetInput( + machine: RemoteRuntimeDiscoveredMachine, +): RemoteRuntimeTargetInput | null { + const route = discoveredRoute(machine); + if (!route) return null; + return { + name: machine.machineName, + hostname: route.replace(/\.$/, ""), + sshUser: null, + port: null, + sshKeyPath: null, + routes: discoveredSshRoutes(machine), + }; +} + function targetFormPrefill( target: RemoteRuntimeTarget, ): RemoteTargetFormPrefill { @@ -463,9 +478,24 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { void loadDiscoveredMachines(); }, [loadDiscoveredMachines]); + const openManualAddForm = useCallback(() => { + setSelectedId(null); + setFormPrefill({ + key: "manual:add", + targetId: null, + name: null, + hostname: "", + sshUser: null, + port: null, + sshKeyPath: null, + routes: null, + }); + setError(null); + setHostKeyTrust(null); + }, []); + const toggleDiscoveredForm = useCallback( (machine: RemoteRuntimeDiscoveredMachine) => { - const route = discoveredRoute(machine); const key = `${machine.id}:${machine.lastSeenAt}`; setSelectedId(null); setError(null); @@ -476,34 +506,20 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { : { key, targetId: null, - name: machine.machineName, - hostname: route?.replace(/\.$/, "") ?? "", - sshUser: null, - port: null, - sshKeyPath: null, - routes: discoveredSshRoutes(machine), + ...(discoveredTargetInput(machine) ?? { + name: machine.machineName, + hostname: "", + sshUser: null, + port: null, + sshKeyPath: null, + routes: discoveredSshRoutes(machine), + }), }, ); }, [], ); - const openManualAddForm = useCallback(() => { - setSelectedId(null); - setFormPrefill({ - key: "manual:add", - targetId: null, - name: null, - hostname: "", - sshUser: null, - port: null, - sshKeyPath: null, - routes: null, - }); - setError(null); - setHostKeyTrust(null); - }, []); - const toggleEditForm = useCallback((target: RemoteRuntimeTarget) => { setSelectedId(target.id); setError(null); @@ -631,11 +647,13 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { } }, [connectTarget, selectedHostKeyTrust]); - const saveAndConnect = useCallback( - async (input: RemoteRuntimeTargetInput) => { + const saveTargetAndConnect = useCallback( + async ( + input: RemoteRuntimeTargetInput, + replacedTargetId: string | null = null, + ) => { setSaving(true); try { - const replacedTargetId = formPrefill?.targetId ?? null; const target = await window.ade.remoteRuntime.saveTarget(input); if (replacedTargetId && replacedTargetId !== target.id) { await window.ade.remoteRuntime.removeTarget(replacedTargetId); @@ -656,7 +674,31 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { setSaving(false); } }, - [connectTarget, formPrefill?.targetId], + [connectTarget], + ); + + const saveAndConnect = useCallback( + async (input: RemoteRuntimeTargetInput) => { + await saveTargetAndConnect(input, formPrefill?.targetId ?? null); + }, + [formPrefill?.targetId, saveTargetAndConnect], + ); + + const connectDiscoveredMachine = useCallback( + async (machine: RemoteRuntimeDiscoveredMachine) => { + const input = discoveredTargetInput(machine); + if (!input) return; + setBusyId(machine.id); + setSelectedId(null); + setHostKeyTrust(null); + setError(null); + try { + await saveTargetAndConnect(input); + } finally { + setBusyId(null); + } + }, + [saveTargetAndConnect], ); const disconnectTarget = useCallback(async (targetId: string) => { @@ -1186,25 +1228,43 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { {discoveredProjectLabel(machine)}
- +
+ + +
{formOpen ? ( From ddf62e83607bc0ebea3ad9b00465de066f6354ea Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:25:47 -0400 Subject: [PATCH 68/80] fix(remote): flatten machines panel wrapper --- .../renderer/components/remoteTargets/RemoteTargetList.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index d7564e6ff..3c46a1298 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -54,10 +54,7 @@ const panelStyle: CSSProperties = { const sectionStyle: CSSProperties = { display: "grid", gap: 12, - borderRadius: 8, - border: `1px solid ${COLORS.border}`, - background: "rgba(255,255,255,0.025)", - padding: 12, + padding: 0, }; const machineRowStyle: CSSProperties = { From 8186f18cd5ba13e44dcc2176bd7e0fd8c32a3b2f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:30:06 -0400 Subject: [PATCH 69/80] fix(remote): remove duplicate machines modal header --- .../renderer/components/app/TopBar.test.tsx | 10 ++++- .../src/renderer/components/app/TopBar.tsx | 42 +++++-------------- 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index f6bed709f..8ee536a74 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -585,12 +585,18 @@ describe("TopBar", () => { fireEvent.click(await screen.findByTitle("Manage remote machines")); - expect(await screen.findByText("Remote machines")).toBeTruthy(); + expect( + await screen.findByRole("dialog", { name: "Remote machines" }), + ).toBeTruthy(); await waitFor(() => expect(events).toEqual(["start"])); fireEvent.click(screen.getByTitle("Close remote machines")); - await waitFor(() => expect(screen.queryByText("Remote machines")).toBeNull()); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "Remote machines" }), + ).toBeNull(), + ); expect(events).toEqual(["start", "end"]); } finally { window.removeEventListener(ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, onStart); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 2bc1c3f93..885daba75 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -2361,41 +2361,21 @@ export function TopBar() { )} role="dialog" aria-modal="true" - aria-labelledby="remote-connections-title" + aria-label="Remote machines" tabIndex={-1} onClick={(event) => event.stopPropagation()} onKeyDown={handleRemotePanelKeyDown} > -
-
- -
-
- Remote machines -
-
- {remoteStatusCount} connected -
-
-
- -
-
+ +
From a6d06570937d00a94f1d2f695cc729400e86df7d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:51:31 -0400 Subject: [PATCH 70/80] fix(remote): respect manual disconnect intent --- .../src/main/services/ipc/runtimeBridge.ts | 7 +- .../remoteConnectionService.test.ts | 68 +++++++++++++++ .../remoteRuntime/remoteConnectionService.ts | 41 +++++++-- apps/desktop/src/preload/global.d.ts | 5 +- apps/desktop/src/preload/preload.test.ts | 1 + apps/desktop/src/preload/preload.ts | 8 +- .../src/renderer/components/app/TopBar.tsx | 85 ++++++++++++++++++- .../remoteTargets/RemoteTargetList.tsx | 17 +++- 8 files changed, 218 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index b146018af..b115003df 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -1125,14 +1125,17 @@ export function registerRuntimeBridge({ ipcMain.handle( IPC.remoteRuntimeDisconnect, - async (event, arg: { id: string }): Promise<{ disconnected: boolean }> => { + async ( + event, + arg: { id: string; manual?: boolean }, + ): Promise<{ disconnected: boolean }> => { const id = typeof arg?.id === "string" ? arg.id.trim() : ""; if (!id) return { disconnected: false }; const currentSubscription = runtimeEventSubscriptions.get(event.sender.id); if (currentSubscription?.bindingKey.startsWith(`remote:${id}:`)) { cleanupRuntimeEventSubscription(event.sender.id); } - remoteConnectionService.disconnect(id); + remoteConnectionService.disconnect(id, { manual: arg.manual !== false }); return { disconnected: true }; }, ); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.test.ts index 5097cff58..e85aa21b2 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.test.ts @@ -73,4 +73,72 @@ describe("RemoteConnectionService", () => { expect(pool.connect).toHaveBeenCalledTimes(1); expect(pool.connect).toHaveBeenCalledWith(previouslyConnected); }); + + it("does not autoconnect a manually disconnected saved target", async () => { + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [previouslyConnected]), + get: vi.fn((id: string) => + id === previouslyConnected.id ? previouslyConnected : null, + ), + } as unknown as RemoteTargetRegistry; + const pool = { + connect: vi.fn(async (target: RemoteRuntimeTarget) => connectResult(target)), + disconnect: vi.fn(), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + service.disconnect(previouslyConnected.id, { manual: true }); + service.startAutoconnect(); + await Promise.resolve(); + service.stopAutoconnect(); + + expect(pool.disconnect).toHaveBeenCalledWith(previouslyConnected.id); + expect(pool.connect).not.toHaveBeenCalled(); + }); + + it("blocks implicit RPC reconnect after manual disconnect until explicit connect", async () => { + const previouslyConnected = target("previously-connected", 1_700_000_000); + const registry = { + list: vi.fn(() => [previouslyConnected]), + get: vi.fn((id: string) => + id === previouslyConnected.id ? previouslyConnected : null, + ), + } as unknown as RemoteTargetRegistry; + const actionResult = { + domain: "file", + action: "read", + result: { ok: true }, + statusHints: {}, + }; + const pool = { + connect: vi.fn(async (target: RemoteRuntimeTarget) => connectResult(target)), + disconnect: vi.fn(), + callActionForTarget: vi.fn(async () => actionResult), + onEntryEvicted: vi.fn(() => () => {}), + } as unknown as RemoteConnectionPool; + + const service = new RemoteConnectionService(registry, pool); + service.disconnect(previouslyConnected.id, { manual: true }); + + await expect( + service.callAction(previouslyConnected.id, "project-1", { + domain: "file", + action: "read", + }), + ).rejects.toThrow(/manually disconnected/i); + expect(pool.callActionForTarget).not.toHaveBeenCalled(); + + await service.connect(previouslyConnected.id); + await expect( + service.callAction(previouslyConnected.id, "project-1", { + domain: "file", + action: "read", + }), + ).resolves.toEqual(actionResult); + + expect(pool.connect).toHaveBeenCalledWith(previouslyConnected); + expect(pool.callActionForTarget).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts index ca56b55ef..b3ce3d129 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts @@ -38,6 +38,10 @@ type RemoteConnectionServiceOptions = { pingTimeoutMs?: number; }; +type RemoteConnectionDisconnectOptions = { + manual?: boolean; +}; + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -61,6 +65,7 @@ function shouldAutoconnectTarget(target: RemoteRuntimeTarget): boolean { export class RemoteConnectionService { private readonly statusById = new Map(); + private readonly manuallyDisconnectedTargetIds = new Set(); private readonly listeners = new Set< (snapshot: RemoteRuntimeConnectionSnapshot) => void >(); @@ -100,6 +105,7 @@ export class RemoteConnectionService { removeTarget(targetId: string): boolean { this.disconnect(targetId); + this.manuallyDisconnectedTargetIds.delete(targetId); this.statusById.delete(targetId); const removed = this.registry.remove(targetId); this.emit(); @@ -159,6 +165,7 @@ export class RemoteConnectionService { startAutoconnect(): void { for (const target of this.registry.list()) { if (!shouldAutoconnectTarget(target)) continue; + if (this.manuallyDisconnectedTargetIds.has(target.id)) continue; void this.connect(target.id).catch(() => {}); } if (this.autoconnectTimer) return; @@ -176,6 +183,7 @@ export class RemoteConnectionService { async connect(targetId: string): Promise { const target = this.requireTarget(targetId); + this.manuallyDisconnectedTargetIds.delete(target.id); this.mergeStatus(target.id, { state: "connecting", lastAttemptedAt: Date.now(), @@ -205,7 +213,13 @@ export class RemoteConnectionService { } } - disconnect(targetId: string): void { + disconnect( + targetId: string, + options: RemoteConnectionDisconnectOptions = {}, + ): void { + if (options.manual) { + this.manuallyDisconnectedTargetIds.add(targetId); + } this.pool.disconnect(targetId); this.mergeStatus(targetId, { state: "idle", lastError: null }); } @@ -217,7 +231,7 @@ export class RemoteConnectionService { } async projects(targetId: string): Promise { - const target = this.requireTarget(targetId); + const target = this.requireTargetForImplicitUse(targetId); try { const value = await this.pool.projectsForTarget(target); const projects = coerceProjects(value); @@ -241,7 +255,7 @@ export class RemoteConnectionService { targetId: string, rootPath: string, ): Promise { - const target = this.requireTarget(targetId); + const target = this.requireTargetForImplicitUse(targetId); try { const value = await this.pool.addProjectForTarget(target, rootPath); const project = coerceConnectionProject(value); @@ -261,7 +275,7 @@ export class RemoteConnectionService { targetId: string, request: RemoteRuntimePortForwardRequest, ): Promise { - const target = this.requireTarget(targetId); + const target = this.requireTargetForImplicitUse(targetId); await this.pool.connect(target); return await this.pool.ensureLocalPortForward(target.id, request); } @@ -356,7 +370,7 @@ export class RemoteConnectionService { projectId: string, request: RemoteRuntimeActionRequest, ): Promise { - const target = this.requireTarget(targetId); + const target = this.requireTargetForImplicitUse(targetId); try { const result = await this.pool.callActionForTarget( target, @@ -384,7 +398,7 @@ export class RemoteConnectionService { projectId: string, request: RemoteRuntimeStreamEventsRequest = {}, ): Promise { - const target = this.requireTarget(targetId); + const target = this.requireTargetForImplicitUse(targetId); try { const result = await this.pool.streamEventsForTarget( target, @@ -418,6 +432,7 @@ export class RemoteConnectionService { ): Promise { for (const target of this.registry.list()) { if (!shouldAutoconnectTarget(target)) continue; + if (this.manuallyDisconnectedTargetIds.has(target.id)) continue; const status = this.statusById.get(target.id); if (status?.state === "connecting") continue; if (status?.state === "connected") { @@ -444,6 +459,7 @@ export class RemoteConnectionService { params: Record, options: { retryOnConnectionError?: boolean } = {}, ): Promise { + this.assertImplicitReconnectAllowed(target.id); try { const result = await this.pool.callMachineForTarget( target, @@ -472,6 +488,19 @@ export class RemoteConnectionService { return target; } + private requireTargetForImplicitUse(targetId: string): RemoteRuntimeTarget { + const target = this.requireTarget(targetId); + this.assertImplicitReconnectAllowed(target.id); + return target; + } + + private assertImplicitReconnectAllowed(targetId: string): void { + if (!this.manuallyDisconnectedTargetIds.has(targetId)) return; + throw new Error( + "Remote machine was manually disconnected. Connect again to use this remote project.", + ); + } + private upsertProject( targetId: string, project: RemoteRuntimeProjectRecord, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 9eb8c5f76..8e28bae4a 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -829,7 +829,10 @@ declare global { id: string, project: RemoteRuntimeProjectRecord, ) => Promise; - disconnect: (id: string) => Promise<{ disconnected: boolean }>; + disconnect: ( + id: string, + options?: { manual?: boolean }, + ) => Promise<{ disconnected: boolean }>; }; keybindings: { get: () => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 7d852455b..151907e40 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -4303,6 +4303,7 @@ describe("preload OAuth bridge", () => { }); expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeDisconnect, { id: "target-1", + manual: true, }); await vi.advanceTimersByTimeAsync(20_000); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 669c26b34..9a3665fd9 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3490,12 +3490,18 @@ contextBridge.exposeInMainWorld("ade", { project: RemoteRuntimeProjectRecord, ): Promise => ipcRenderer.invoke(IPC.remoteRuntimeCheckLocalWork, { id, project }), - disconnect: async (id: string): Promise<{ disconnected: boolean }> => { + disconnect: async ( + id: string, + options: { manual?: boolean } = {}, + ): Promise<{ disconnected: boolean }> => { const trimmedId = typeof id === "string" ? id.trim() : ""; + const manual = options.manual !== false; const result = (await ipcRenderer.invoke(IPC.remoteRuntimeDisconnect, { id: trimmedId, + manual, })) as { disconnected: boolean }; if ( + manual && result.disconnected && currentProjectBinding?.kind === "remote" && currentProjectBinding.targetId === trimmedId diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 885daba75..89e7d08de 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -42,6 +42,7 @@ import type { OpenProjectBinding, RecentProjectSummary, RemoteRuntimeConnectionSnapshot, + RemoteRuntimeTarget, SyncRoleSnapshot, AppResourceUsageSnapshot, } from "../../../shared/types"; @@ -51,6 +52,7 @@ import { HelpMenu } from "../onboarding/HelpMenu"; import { LinearQuickViewButton } from "./LinearQuickViewButton"; import { PublishToGitHubDialog } from "../projects/PublishToGitHubDialog"; import { RemoteTargetList } from "../remoteTargets/RemoteTargetList"; +import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { SyncDevicesSection } from "../settings/SyncDevicesSection"; import { HeaderUsageControl } from "../usage/HeaderUsageControl"; import { appResourcePressureLevel, getAppResourceUsageCoalesced, resourcePressureDescription } from "../../lib/resourcePressure"; @@ -952,6 +954,11 @@ export function TopBar() { ); const [phoneSyncOpen, setPhoneSyncOpen] = useState(false); const [remotePanelOpen, setRemotePanelOpen] = useState(false); + const { + state: remoteDisconnectConfirmState, + confirmAsync: confirmRemoteDisconnect, + close: closeRemoteDisconnectConfirm, + } = useConfirmDialog(); const [remoteSnapshot, setRemoteSnapshot] = useState(null); const [feedbackOpen, setFeedbackOpen] = useState(false); @@ -1465,6 +1472,76 @@ export function TopBar() { switchRemoteProject, ]); + const handleRemoteTargetDisconnectRequested = useCallback( + async (target: RemoteRuntimeTarget): Promise => { + const latestRemoteTabs = openRemoteProjectTabsRef.current; + const affectedTabs = latestRemoteTabs.filter( + (entry) => entry.targetId === target.id, + ); + const targetName = target.name || target.hostname; + const affectedCount = affectedTabs.length; + const affectedProjectLines = + affectedTabs.length > 0 + ? affectedTabs.map((entry) => `- ${entry.displayName}`).join("\n") + : ""; + const message = + affectedCount > 0 + ? [ + `${affectedCount} open project tab${affectedCount === 1 ? "" : "s"} use this remote connection:`, + affectedProjectLines, + "", + "Disconnecting will close those project tabs. ADE will not reconnect to this machine until you connect again.", + ].join("\n") + : "Disconnecting will stop this remote connection. ADE will not reconnect to this machine until you connect again."; + + const confirmed = await confirmRemoteDisconnect({ + title: `Disconnect ${targetName}?`, + message, + confirmLabel: "DISCONNECT", + danger: true, + }); + if (!confirmed) return false; + if (affectedTabs.length === 0) return true; + + const affectedKeys = new Set(affectedTabs.map((entry) => entry.key)); + const nextRemoteTabs = latestRemoteTabs.filter( + (entry) => !affectedKeys.has(entry.key), + ); + openRemoteProjectTabsRef.current = nextRemoteTabs; + setOpenRemoteProjectTabs(nextRemoteTabs); + + const latestState = useAppStore.getState(); + const latestRemoteBinding = + latestState.projectBinding?.kind === "remote" + ? latestState.projectBinding + : null; + if (!latestRemoteBinding || !affectedKeys.has(latestRemoteBinding.key)) { + return true; + } + + const nextRemoteTab = nextRemoteTabs[0] ?? null; + if (nextRemoteTab) { + latestState.switchRemoteProject( + nextRemoteTab.targetId, + nextRemoteTab.projectId, + ).catch(() => {}); + return true; + } + + const nextLocalRoot = + openProjectTabRootsRef.current[ + openProjectTabRootsRef.current.length - 1 + ] ?? null; + if (nextLocalRoot) { + latestState.switchProjectToPath(nextLocalRoot).catch(() => {}); + } else { + latestState.closeProject().catch(() => {}); + } + return true; + }, + [confirmRemoteDisconnect], + ); + const handleRelocate = useCallback( (oldPath: string) => { setRelocatingPath(oldPath); @@ -2347,6 +2424,10 @@ export function TopBar() { {/* Overlay panels & modals — kept outside the gap-6 wrapper so they never participate in flex gap accounting when toggled open. */} + {remotePanelOpen ? (
- +
diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index 3c46a1298..a95f21a92 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -40,6 +40,9 @@ import { type RemoteTargetListProps = { onConnected?: (result: RemoteRuntimeConnectResult) => void; + onDisconnectRequested?: ( + target: RemoteRuntimeTarget, + ) => boolean | Promise; }; type ConnectTargetOptions = { @@ -337,7 +340,10 @@ function formatRemoteTargetError(error: unknown): string { return message || "Remote connection failed."; } -export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { +export function RemoteTargetList({ + onConnected, + onDisconnectRequested, +}: RemoteTargetListProps) { const [targets, setTargets] = useState([]); const [connectionSnapshot, setConnectionSnapshot] = useState(null); @@ -699,10 +705,15 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { ); const disconnectTarget = useCallback(async (targetId: string) => { + const target = targets.find((entry) => entry.id === targetId) ?? null; + if (target && onDisconnectRequested) { + const shouldDisconnect = await onDisconnectRequested(target); + if (!shouldDisconnect) return; + } setBusyId(targetId); setSelectedId(targetId); try { - await window.ade.remoteRuntime.disconnect(targetId); + await window.ade.remoteRuntime.disconnect(targetId, { manual: true }); setConnected((current) => current?.target.id === targetId ? null : current, ); @@ -733,7 +744,7 @@ export function RemoteTargetList({ onConnected }: RemoteTargetListProps) { } finally { setBusyId(null); } - }, []); + }, [onDisconnectRequested, targets]); const removeTarget = useCallback( async (targetId: string) => { From 423d6aebc26f1c23a6c04ec6e66f2e3237782c54 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:04:05 -0400 Subject: [PATCH 71/80] fix(remote): surface reconnect and stale project state --- .../src/renderer/components/app/AppShell.tsx | 191 +++++++++++++++++- .../renderer/components/app/TopBar.test.tsx | 77 ++++++- .../src/renderer/components/app/TopBar.tsx | 65 ++++-- .../remoteTargets/RemoteTargetList.test.tsx | 54 ++++- 4 files changed, 370 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index a5e578a8c..602af7913 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -2,9 +2,11 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { ArrowSquareOut, CheckCircle, + CircleNotch, GitBranch, GithubLogo, GitPullRequest, + PlugsConnected, WarningCircle, XCircle, } from "@phosphor-icons/react"; @@ -31,6 +33,7 @@ import type { PrEventPayload, ProjectInfo, OpenProjectBinding, + RemoteRuntimeConnectionSnapshot, TerminalSessionSummary, } from "../../../shared/types"; import { @@ -142,6 +145,14 @@ type LinearWorkflowToast = { >; }; +type RemoteConnectionNotice = { + key: string; + state: "connecting" | "error" | "idle"; + badge: string; + title: string; + body: string; +}; + type StaleCliNotice = { count: number; oldestStartedAt: string; @@ -256,6 +267,13 @@ function getPrToastIcon(kind: PrToast["event"]["kind"]) { return GitPullRequest; } +function cleanRemoteConnectionError(message: string | null): string { + return (message ?? "") + .replace(/^Error invoking remote method '[^']+':\s*/i, "") + .replace(/^Error:\s*/i, "") + .trim(); +} + export function AppShell({ children }: { children: React.ReactNode }) { const location = useLocation(); const navigate = useNavigate(); @@ -297,6 +315,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { const [staleCliNotice, setStaleCliNotice] = useState(null); const dismissedStaleCliNoticeKeyRef = useRef(null); + const [remoteSnapshot, setRemoteSnapshot] = + useState(null); + const [dismissedRemoteNoticeKey, setDismissedRemoteNoticeKey] = + useState(null); const [aiFailure, setAiFailure] = useState(null); const [aiMockProvider, setAiMockProvider] = useState<{ createdAt: string; @@ -315,6 +337,8 @@ export function AppShell({ children }: { children: React.ReactNode }) { const dismissGithubBanner = useAppStore((s) => s.dismissGithubBanner); const currentProjectRoot = projectBinding?.kind === "remote" ? projectBinding.rootPath : (project?.rootPath ?? null); + const activeRemoteBinding = + projectBinding?.kind === "remote" ? projectBinding : null; const isRemoteProject = projectBinding?.kind === "remote"; const missingAiBannerDismissed = Boolean( currentProjectRoot && dismissedMissingAiBannerRoots[currentProjectRoot], @@ -357,6 +381,28 @@ export function AppShell({ children }: { children: React.ReactNode }) { ); }, [currentProjectRoot, location.pathname, showWelcome]); + useEffect(() => { + const remoteRuntime = window.ade.remoteRuntime; + if (!remoteRuntime?.getConnectionSnapshot) return; + let cancelled = false; + void remoteRuntime + .getConnectionSnapshot() + .then((snapshot) => { + if (!cancelled) setRemoteSnapshot(snapshot); + }) + .catch(() => { + if (!cancelled) setRemoteSnapshot(null); + }); + const unsubscribe = + remoteRuntime.onConnectionSnapshotChanged?.((snapshot) => { + if (!cancelled) setRemoteSnapshot(snapshot); + }) ?? (() => {}); + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); + useEffect(() => { disposeTerminalRuntimesForProjectChange(project?.rootPath ?? null, projectRevision); }, [project?.rootPath, projectRevision]); @@ -1057,6 +1103,58 @@ export function AppShell({ children }: { children: React.ReactNode }) { staleCliNotice && currentProjectRoot ? `${currentProjectRoot}:${staleCliNotice.count}:${staleCliNotice.oldestStartedAt}` : null; + const activeRemoteConnection = + activeRemoteBinding && remoteSnapshot + ? remoteSnapshot.connections.find( + (entry) => entry.target.id === activeRemoteBinding.targetId, + ) ?? null + : null; + const remoteConnectionNotice = useMemo(() => { + if (!activeRemoteBinding || !remoteSnapshot) return null; + const state = activeRemoteConnection?.state ?? "idle"; + if (state === "connected") return null; + const lastError = cleanRemoteConnectionError( + activeRemoteConnection?.lastError ?? null, + ); + const key = [ + activeRemoteBinding.key, + state, + activeRemoteConnection?.lastAttemptedAt ?? 0, + lastError, + ].join(":"); + if (state === "connecting") { + return { + key, + state, + badge: "Reconnecting", + title: `Reconnecting to ${activeRemoteBinding.runtimeName}`, + body: "ADE is restoring the remote session. The project stays open while it retries.", + }; + } + if (state === "error") { + return { + key, + state, + badge: "Disconnected", + title: `${activeRemoteBinding.runtimeName} is unreachable`, + body: lastError + ? `${lastError} ADE will keep trying to reconnect while this project is open.` + : "ADE will keep trying to reconnect while this project is open.", + }; + } + return { + key, + state, + badge: "Disconnected", + title: `${activeRemoteBinding.runtimeName} is not connected`, + body: "ADE will try to reconnect when the remote project needs runtime data.", + }; + }, [activeRemoteBinding, activeRemoteConnection, remoteSnapshot]); + const visibleRemoteConnectionNotice = + remoteConnectionNotice && + remoteConnectionNotice.key !== dismissedRemoteNoticeKey + ? remoteConnectionNotice + : null; return (
@@ -1238,8 +1336,99 @@ export function AppShell({ children }: { children: React.ReactNode }) { children )}
- {staleCliNotice || prToasts.length > 0 ? ( + {visibleRemoteConnectionNotice || staleCliNotice || prToasts.length > 0 ? (
+ {visibleRemoteConnectionNotice ? ( +
+
+
+ {visibleRemoteConnectionNotice.state === "connecting" ? ( + + ) : ( + + )} +
+
+
+
+ + {visibleRemoteConnectionNotice.badge} + +
+ {visibleRemoteConnectionNotice.title} +
+
+ +
+
+ {visibleRemoteConnectionNotice.body} +
+
+ +
+
+
+
+ ) : null} {staleCliNotice ? (
diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 8ee536a74..f9619f6cd 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -93,6 +93,41 @@ function makeSyncSnapshot(overrides: Record = {}) { }; } +function makeRemoteConnectionSnapshot( + targetId: string, + name = "Mac Studio", + overrides: Record = {}, +) { + const state = overrides.state ?? "connected"; + return { + connections: [ + { + target: { + id: targetId, + name, + hostname: "studio.local", + sshUser: "admin", + port: 22, + sshKeyPath: null, + routes: [], + lastSeenArch: "darwin-arm64", + runtimeBinaryVersion: "1.0.0-beta.1", + lastConnectedAt: 1_700_000_000, + }, + state, + arch: "darwin-arm64", + version: "1.0.0-beta.1", + projects: [], + lastError: overrides.lastError ?? null, + lastAttemptedAt: overrides.lastAttemptedAt ?? null, + connectedAt: state === "connected" ? 1_700_000_000 : null, + }, + ], + connectedCount: state === "connected" ? 1 : 0, + updatedAt: 1_700_000_000, + }; +} + function resetStore() { useAppStore.setState({ project: { rootPath: "/Users/arul/ADE", name: "ADE" } as any, @@ -313,6 +348,8 @@ describe("TopBar", () => { }); it("renders a remote project tab without local sync polling", async () => { + (globalThis.window.ade.remoteRuntime.getConnectionSnapshot as any) + .mockResolvedValue(makeRemoteConnectionSnapshot("studio")); useAppStore.setState({ project: { rootPath: "/srv/ade/remote-app", displayName: "Remote App", baseRef: "main" }, projectBinding: { @@ -330,7 +367,7 @@ describe("TopBar", () => { render(); - expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app")).toBeTruthy(); + expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app (Connected)")).toBeTruthy(); expect(screen.getByText("Remote App")).toBeTruthy(); expect(screen.getByLabelText("Remote: Mac Studio")).toBeTruthy(); expect(screen.getByRole("button", { name: "Remote, connected" })).toBeTruthy(); @@ -338,7 +375,39 @@ describe("TopBar", () => { expect(screen.queryByTitle("Connect a phone to this machine")).toBeNull(); }); + it("marks a remote project tab disconnected when the target snapshot is errored", async () => { + (globalThis.window.ade.remoteRuntime.getConnectionSnapshot as any) + .mockResolvedValue( + makeRemoteConnectionSnapshot("studio", "Mac Studio", { + state: "error", + lastError: "Remote ADE service connection was interrupted.", + lastAttemptedAt: 1_700_000_001, + }), + ); + useAppStore.setState({ + project: { rootPath: "/srv/ade/remote-app", displayName: "Remote App", baseRef: "main" }, + projectBinding: { + kind: "remote", + key: "remote:studio:project-1", + targetId: "studio", + runtimeName: "Mac Studio", + projectId: "project-1", + rootPath: "/srv/ade/remote-app", + displayName: "Remote App", + }, + projectHydrated: true, + showWelcome: false, + } as any); + + render(); + + expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app (Disconnected)")).toBeTruthy(); + expect(screen.getByLabelText("Disconnected: Mac Studio")).toBeTruthy(); + }); + it("keeps local tabs visible when a remote project is active", async () => { + (globalThis.window.ade.remoteRuntime.getConnectionSnapshot as any) + .mockResolvedValue(makeRemoteConnectionSnapshot("studio")); render(); const localTab = await screen.findByTitle("/Users/arul/ADE"); @@ -360,7 +429,7 @@ describe("TopBar", () => { } as any); }); - expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app")).toBeTruthy(); + expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app (Connected)")).toBeTruthy(); expect(screen.getByTitle("/Users/arul/ADE")).toBeTruthy(); fireEvent.click(localTab); @@ -444,6 +513,8 @@ describe("TopBar", () => { rootPath: "/Users/admin/Projects/perf pass", displayName: "perf pass", }; + (globalThis.window.ade.remoteRuntime.getConnectionSnapshot as any) + .mockResolvedValue(makeRemoteConnectionSnapshot("target-1")); useAppStore.setState({ project: { rootPath: remoteBinding.rootPath, @@ -463,7 +534,7 @@ describe("TopBar", () => { render(); await waitFor(() => { - expect(screen.getByTitle("Mac Studio: /Users/admin/Projects/perf pass")).toBeTruthy(); + expect(screen.getByTitle("Mac Studio: /Users/admin/Projects/perf pass (Connected)")).toBeTruthy(); expect(screen.queryByTitle("/Users/admin/Projects/perf pass")).toBeNull(); expect(useAppStore.getState().openProjectTabRoots).toEqual([]); }); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 89e7d08de..c0c5e353c 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1946,22 +1946,42 @@ export function TopBar() { <> {openRemoteProjectTabs.map((remoteTab) => { const isCurrentRemote = remoteBinding?.key === remoteTab.key; + const remoteTabConnection = + remoteSnapshot?.connections.find( + (entry) => entry.target.id === remoteTab.targetId, + ) ?? null; + const remoteTabState = remoteTabConnection?.state ?? "idle"; + const remoteTabConnected = remoteTabState === "connected"; + const remoteTabConnecting = remoteTabState === "connecting"; + const remoteTabDisconnected = + remoteTabState === "error" || remoteTabState === "idle"; + const remoteTabStatusLabel = remoteTabConnected + ? "Connected" + : remoteTabConnecting + ? "Reconnecting" + : "Disconnected"; return (
handleSwitchRemoteProject(remoteTab)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { @@ -1980,12 +2000,28 @@ export function TopBar() { {remoteTab.displayName} - + {remoteTabConnecting ? ( + + ) : remoteTabDisconnected ? ( + + ) : ( + + )}