From 15d9c466f231e06689b6572b4658f797dc408385 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 17:16:45 -0400 Subject: [PATCH 1/9] fix(app): honor hash routes in browser router --- .../src/renderer/components/app/App.tsx | 26 ++++++++++++++++--- .../components/app/App.workKeepAlive.test.tsx | 17 ++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 005bc28aa..8cabe8a77 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -15,11 +15,10 @@ import { // the history API is not tied to a normal origin. // Relying only on `__adeBrowserMock` breaks when the flag is not set at module-eval // time, which can strand Cursor's embedded browser on a single path. -const Router = +const usesBrowserRouter = typeof window !== "undefined" && - (window.location.protocol === "http:" || window.location.protocol === "https:") - ? BrowserRouter - : HashRouter; + (window.location.protocol === "http:" || window.location.protocol === "https:"); +const Router = usesBrowserRouter ? BrowserRouter : HashRouter; import { AppShell } from "./AppShell"; import { RunPage } from "../run/RunPage"; import { ProjectSetupPage } from "../onboarding/ProjectSetupPage"; @@ -328,6 +327,24 @@ function AppNavigationBridge() { return null; } +function BrowserHashRouteBridge() { + const navigate = useNavigate(); + + React.useEffect(() => { + if (!usesBrowserRouter) return; + const syncHashRoute = () => { + const hash = window.location.hash; + if (!hash.startsWith("#/")) return; + navigate(hash.slice(1), { replace: true }); + }; + syncHashRoute(); + window.addEventListener("hashchange", syncHashRoute); + return () => window.removeEventListener("hashchange", syncHashRoute); + }, [navigate]); + + return null; +} + export function App() { const theme = useAppStore((s) => s.theme); const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); @@ -356,6 +373,7 @@ export function App() {
+ } /> }> 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 e7db1d3ad..39071a70c 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -94,6 +94,10 @@ vi.mock("../files/FilesPage", async () => { }; }); +vi.mock("../lanes/LanesPage", () => ({ + LanesPage: () =>
, +})); + describe("App Work route keep-alive", () => { beforeEach(() => { vi.clearAllMocks(); @@ -187,4 +191,17 @@ describe("App Work route keep-alive", () => { expect(screen.queryByTestId("work-page")).toBeNull(); expect(workLifecycle.mounts).toBe(0); }); + + it("converts legacy hash app routes into BrowserRouter paths", async () => { + window.history.replaceState({}, "", "/work#/lanes"); + const { App } = await import("./App"); + + render(); + + await screen.findByTestId("lanes-page"); + await waitFor(() => { + expect(window.location.pathname).toBe("/lanes"); + expect(window.location.hash).toBe(""); + }); + }); }); From 48ea5a78a973b1ae372c6930a82c134d35400a68 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 17:24:47 -0400 Subject: [PATCH 2/9] perf(lanes): skip disabled local runtime bridge real lanes baseline failed at lanes.idle-at-rest with V8 OOM; optimized scenarios all passed, fitness 7028.82 --- apps/desktop/src/preload/preload.test.ts | 46 ++++++++++++++++++++++++ apps/desktop/src/preload/preload.ts | 20 +++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index ba4466305..35823e0c0 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -10,6 +10,7 @@ describe("preload OAuth bridge", () => { afterEach(() => { vi.resetModules(); vi.doUnmock("electron"); + delete process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON; delete (globalThis as any).__adeBridge; }); @@ -317,6 +318,51 @@ describe("preload OAuth bridge", () => { expect(invoke).toHaveBeenCalledWith(IPC.lanesOpenFolder, { laneId: "lane-1" }); }); + it("skips local runtime IPC when the local runtime daemon is disabled", async () => { + process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON = "1"; + const binding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + if (channel === IPC.lanesList) return []; + 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; + await expect(bridge.lanes.list()).resolves.toEqual([]); + + expect(invoke).toHaveBeenCalledWith(IPC.appGetWindowSession); + expect(invoke).toHaveBeenCalledWith(IPC.lanesList, {}); + expect(invoke).not.toHaveBeenCalledWith( + IPC.localRuntimeCallAction, + expect.anything(), + ); + }); + it("routes project local-data cleanup through a remote project runtime when bound", async () => { const binding = { kind: "remote", diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 41195f9e8..7522e4eca 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1029,11 +1029,14 @@ const gitBranchesCache = createKeyedShortIpcCache( 2_000, ); +const localRuntimeDaemonDisabled = + process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1"; + const allowLocalRuntimeFallback = process.env.ADE_LOCAL_RUNTIME_FALLBACK !== "0" && ( process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1" || - process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1" || + localRuntimeDaemonDisabled || process.env.ADE_PACKAGE_CHANNEL === "alpha" ); @@ -1062,7 +1065,10 @@ function rememberProjectBinding(binding: OpenProjectBinding | null): void { projectBindingGeneration += 1; resetRemoteRuntimeEventDedup(nextKey); } - if (binding?.kind === "remote" || binding?.kind === "local") { + if ( + binding?.kind === "remote" || + (binding?.kind === "local" && !localRuntimeDaemonDisabled) + ) { ensureRemoteRuntimeEventPump(); } } @@ -1128,6 +1134,7 @@ async function callLocalProjectActionIfBound( action: string, request: Omit = {}, ): Promise<{ handled: true; result: T } | { handled: false }> { + if (localRuntimeDaemonDisabled) return { handled: false }; const binding = await getLocalProjectBinding(); if (!binding) return { handled: false }; try { @@ -1194,6 +1201,7 @@ async function callLocalProjectSyncIfBound( method: string, params: Record = {}, ): Promise<{ handled: true; result: T } | { handled: false }> { + if (localRuntimeDaemonDisabled) return { handled: false }; const binding = await getLocalProjectBinding(); if (!binding) return { handled: false }; try { @@ -1384,6 +1392,14 @@ async function pollRemoteRuntimeEvents(): Promise { resetRemoteRuntimeEventDedup(null); return; } + if (binding.kind === "local" && localRuntimeDaemonDisabled) { + remoteRuntimeEventCursor = 0; + remoteRuntimeEventBindingKey = null; + remoteRuntimeEventGeneration = projectBindingGeneration; + remoteRuntimeEventStartedAtMs = 0; + resetRemoteRuntimeEventDedup(null); + return; + } if ( remoteRuntimeEventBindingKey !== binding.key || From 0e9511f41ee391b75117761e6398dd7a7f1b3593 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 17:47:47 -0400 Subject: [PATCH 3/9] perf(lanes): suppress duplicate git actions fullscreen pane UI audit: Git Actions expand went from 2 toolbars to 1. Post-marker IPC changed from 30 calls / 223ms to 29 calls / 192ms, removing one duplicate ade.git.getSyncStatus. --- .../components/lanes/LanesPage.test.ts | 25 ++++++++++++++++ .../renderer/components/lanes/LanesPage.tsx | 30 ++++++++++++++----- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts index f5052b8ce..cc87dbc0c 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -5,6 +5,7 @@ import { resolveCreateLaneRequest, resolveLaneIdsDeepLinkSelection, selectLanePrTag, + shouldMountGitActionsPane, } from "./LanesPage"; import type { LaneSummary, PrSummary } from "../../../shared/types"; @@ -235,3 +236,27 @@ describe("selectLanePrTag", () => { ).toBe(false); }); }); + +describe("shouldMountGitActionsPane", () => { + it("keeps the fullscreen Git Actions pane mounted while suppressing the hidden inline duplicate", () => { + expect(shouldMountGitActionsPane({ + laneId: "lane-1", + expandedGitActionsLaneId: "lane-1", + surface: "inline", + })).toBe(false); + + expect(shouldMountGitActionsPane({ + laneId: "lane-1", + expandedGitActionsLaneId: "lane-1", + surface: "git-actions-fullscreen", + })).toBe(true); + }); + + it("keeps inline Git Actions mounted for lanes that are not expanded", () => { + expect(shouldMountGitActionsPane({ + laneId: "lane-2", + expandedGitActionsLaneId: "lane-1", + surface: "inline", + })).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 021292237..c54b5906d 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -80,6 +80,16 @@ type RebaseScopePromptState = { resolve: (scope: RebaseScope | null) => void; }; +type LanePaneSurface = "inline" | "git-actions-fullscreen" | "lane-fullscreen"; + +export function shouldMountGitActionsPane(args: { + laneId: string | null; + expandedGitActionsLaneId: string | null; + surface: LanePaneSurface; +}): boolean { + return args.surface !== "inline" || !args.laneId || args.expandedGitActionsLaneId !== args.laneId; +} + type RebasePushReviewState = { runId: string; lanes: Array<{ laneId: string; laneName: string; selected: boolean }>; @@ -2213,13 +2223,19 @@ export function LanesPage() { /* ---- Pane configs ---- */ - const getPaneConfigs = useCallback((laneId: string | null) => { + const getPaneConfigs = useCallback((laneId: string | null, options?: { surface?: LanePaneSurface }) => { + const surface = options?.surface ?? "inline"; const laneDetail = laneId ? lanePaneDetails[laneId] ?? EMPTY_LANE_PANE_DETAIL : EMPTY_LANE_PANE_DETAIL; const laneSnapshot = laneId ? laneSnapshotByLaneId.get(laneId) ?? null : null; const pendingLinearIssueContext = laneId && linearIssueChatContextRequest?.laneId === laneId ? linearIssueChatContextRequest : null; + const mountGitActionsPane = shouldMountGitActionsPane({ + laneId, + expandedGitActionsLaneId, + surface, + }); return { "git-actions": { title: "Git Actions", @@ -2248,7 +2264,7 @@ export function LanesPage() { ), bodyClassName: "overflow-hidden", - children: ( + children: mountGitActionsPane ? ( handleClearLanePaneDetailSelection(laneId) : undefined} /> - ) + ) : null }, "work": { title: "Work", @@ -3286,7 +3302,7 @@ export function LanesPage() { key={`lanes:single:${gridResetKey}`} layoutId={`lanes:tiling:${LANES_TILING_LAYOUT_VERSION}${laneTilingLayoutSuffix}:${visibleLaneIds[0]}`} tree={laneTilingTree} - panes={getPaneConfigs(visibleLaneIds[0] ?? null)} + panes={getPaneConfigs(visibleLaneIds[0] ?? null, { surface: "inline" })} className="flex-1 min-h-0" /> ) : ( @@ -3326,7 +3342,7 @@ export function LanesPage() {
@@ -3344,7 +3360,7 @@ export function LanesPage() {
@@ -3366,7 +3382,7 @@ export function LanesPage() { From 2e6b3406c50e7dbeb63ce52ed7375b13b4a57d40 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 17:51:41 -0400 Subject: [PATCH 4/9] perf(lanes): fast-path disabled sync runtime UI audit: sync IPC in local-runtime-disabled perf mode went from 4 failed calls / 1145ms to 3 successful calls / 2ms. --- .../src/main/services/ipc/registerIpc.ts | 100 ++++++++++++++++-- 1 file changed, 91 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index d9690c1e4..0d5e9f2b7 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1765,14 +1765,87 @@ export function registerIpc({ if (getSyncService) return getSyncService() ?? null; return getCtx().syncService ?? null; }; + const resolveOptionalSyncService = async (): Promise | null> => + resolveSyncService + ? (await resolveSyncService()) ?? null + : getOptionalSyncService(); + const localRuntimeDaemonDisabled = process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1"; const allowLocalRuntimeFallback = process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1" || - process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1"; + localRuntimeDaemonDisabled; + + const buildUnavailableSyncSnapshot = (): SyncRoleSnapshot => { + const now = new Date().toISOString(); + const platform = + process.platform === "darwin" + ? "macOS" + : process.platform === "win32" + ? "windows" + : process.platform === "linux" + ? "linux" + : "unknown"; + const localDevice: SyncDeviceRecord = { + deviceId: "local-runtime-disabled", + siteId: "local-runtime-disabled", + name: "Local desktop", + platform, + deviceType: "desktop", + createdAt: now, + updatedAt: now, + lastSeenAt: now, + lastHost: null, + lastPort: null, + tailscaleIp: null, + ipAddresses: [], + metadata: { unavailableReason: "local_runtime_daemon_disabled" }, + }; + return { + mode: "standalone", + role: "brain", + localDevice, + currentBrain: localDevice, + clusterState: null, + bootstrapToken: null, + pairingPin: null, + pairingPinConfigured: false, + pairingConnectInfo: null, + connectedPeers: [], + tailnetDiscovery: { + state: "disabled", + serviceName: "ade-sync", + servicePort: 0, + target: null, + updatedAt: null, + error: null, + stderr: null, + }, + client: { + state: "disconnected", + host: null, + port: null, + connectedAt: null, + lastSeenAt: null, + latencyMs: null, + syncLag: null, + lastRemoteDbVersion: 0, + brainDeviceId: localDevice.deviceId, + hostName: localDevice.name, + error: null, + message: "Sync service unavailable in local runtime disabled mode.", + savedDraft: null, + }, + transferReadiness: { + ready: true, + blockers: [], + survivableState: [], + }, + survivableStateText: "Sync service unavailable in local runtime disabled mode.", + blockingStateText: "", + }; + }; const requireSyncService = async (): Promise> => { - const service = resolveSyncService - ? await resolveSyncService() - : getOptionalSyncService(); + const service = await resolveOptionalSyncService(); if (!service) { throw new Error("Sync service is not available."); } @@ -1792,6 +1865,7 @@ export function registerIpc({ event: { sender: Electron.WebContents }, action: (pool: LocalRuntimeConnectionPool, rootPath: string) => Promise, ): Promise => { + if (localRuntimeDaemonDisabled) return null; if (!localRuntimeConnectionPool) return null; const rootPath = getLocalRuntimeRootForEvent(event); if (!rootPath) return null; @@ -4076,7 +4150,12 @@ export function registerIpc({ pool.syncStatusForRoot(rootPath, arg ?? {}) ); if (runtimeStatus) return runtimeStatus; - return await (await requireSyncService()).getStatus({ + const service = await resolveOptionalSyncService(); + if (!service) { + if (localRuntimeDaemonDisabled) return buildUnavailableSyncSnapshot(); + throw new Error("Sync service is not available."); + } + return await service.getStatus({ includeTransferReadiness: arg?.includeTransferReadiness, forceTransferReadiness: arg?.forceTransferReadiness, }); @@ -4204,7 +4283,7 @@ export function registerIpc({ async (event, arg: { laneIds?: string[] | null }): Promise => { const laneIds = Array.isArray(arg?.laneIds) ? arg.laneIds : []; const rootPath = getLocalRuntimeRootForEvent(event); - if (localRuntimeConnectionPool && rootPath) { + if (!localRuntimeDaemonDisabled && localRuntimeConnectionPool && rootPath) { try { await localRuntimeConnectionPool.callSyncForRoot(rootPath, "sync.setActiveLanePresence", { laneIds }); return; @@ -4214,9 +4293,12 @@ export function registerIpc({ } } } - await (await requireSyncService()).setActiveLanePresence( - laneIds, - ); + const service = await resolveOptionalSyncService(); + if (!service) { + if (localRuntimeDaemonDisabled) return; + throw new Error("Sync service is not available."); + } + await service.setActiveLanePresence(laneIds); }, ); From 10baf1e322cf77d865cd7dde011820490a982eb0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 18:24:54 -0400 Subject: [PATCH 5/9] perf(lanes): scope lane refreshes during UI actions --- .../components/lanes/LaneGitActionsPane.tsx | 12 ++- .../renderer/components/lanes/LanesPage.tsx | 7 +- .../src/renderer/state/appStore.test.ts | 78 +++++++++++++++++++ apps/desktop/src/renderer/state/appStore.ts | 43 ++++++++-- 4 files changed, 127 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 487bd9e26..42b625110 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -725,7 +725,11 @@ export function LaneGitActionsPane({ // best effort } } - await Promise.all([refreshChanges(targetLaneId), refreshLanes(), refreshGitMeta(targetLaneId)]); + await Promise.all([ + refreshChanges(targetLaneId), + refreshLanes({ includeStatus: true, includeSnapshots: false }), + refreshGitMeta(targetLaneId), + ]); if (isViewingLane(targetLaneId)) { setCommitTimelineKey((prev) => prev + 1); } @@ -843,7 +847,11 @@ export function LaneGitActionsPane({ }; const completeCommitRefresh = useCallback(async (targetLaneId: string) => { - await Promise.all([refreshChanges(targetLaneId), refreshLanes(), refreshGitMeta(targetLaneId)]); + await Promise.all([ + refreshChanges(targetLaneId), + refreshLanes({ includeStatus: true, includeSnapshots: false }), + refreshGitMeta(targetLaneId), + ]); if (isViewingLane(targetLaneId)) { setCommitTimelineKey((prev) => prev + 1); setCommitMessage(""); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index c54b5906d..bf6fccbe4 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -987,7 +987,8 @@ export function LanesPage() { let timer: ReturnType | null = null; const refreshRuntimeOnly = () => refreshLanes({ - includeStatus: true, + includeStatus: false, + includeSnapshots: true, includeConflictStatus: false, includeRebaseSuggestions: false, includeAutoRebaseStatus: false, @@ -3416,7 +3417,7 @@ export function LanesPage() { setActiveLaneIds(allIds); }} onBatchManage={openBatchManage} - onAppearanceChanged={() => refreshLanes().catch(() => {})} + onAppearanceChanged={() => refreshLanes({ includeStatus: false }).catch(() => {})} /> ) : null} @@ -3447,7 +3448,7 @@ export function LanesPage() { }} onArchive={() => { archiveManagedLanes().catch(() => {}); }} onDelete={() => { deleteManagedLanes().catch(() => {}); }} - onAppearanceChanged={() => refreshLanes().catch(() => {})} + onAppearanceChanged={() => refreshLanes({ includeStatus: false }).catch(() => {})} /> diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 6b82cb4d6..a3ec4007c 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -300,6 +300,84 @@ describe("appStore", () => { expect(useAppStore.getState().lanes).toEqual(lanes); }); + it("refreshLanes preserves prior git status for statusless lane refreshes", async () => { + useAppStore.setState({ + lanes: [{ id: "lane-lite", name: "Lane lite", status: { dirty: true }, parentStatus: { ahead: 1 } }] as any[], + }); + (window.ade.lanes.list as any).mockResolvedValueOnce([{ id: "lane-lite", name: "Lane lite", color: "#7dd3fc" }] as any[]); + + await useAppStore.getState().refreshLanes({ includeStatus: false }); + + expect(window.ade.lanes.list).toHaveBeenCalledWith({ + includeArchived: false, + includeStatus: false, + }); + expect(useAppStore.getState().lanes[0]).toEqual( + expect.objectContaining({ + id: "lane-lite", + color: "#7dd3fc", + status: { dirty: true }, + parentStatus: { ahead: 1 }, + }), + ); + }); + + it("refreshLanes can update lane git status without snapshot decorations", async () => { + const lanes = [{ id: "lane-status", name: "Lane status", status: { dirty: true } }] as any[]; + (window.ade.lanes.list as any).mockResolvedValueOnce(lanes); + + await useAppStore.getState().refreshLanes({ includeStatus: true, includeSnapshots: false }); + + expect(window.ade.lanes.list).toHaveBeenCalledWith({ + includeArchived: false, + includeStatus: true, + }); + expect(window.ade.lanes.listSnapshots).not.toHaveBeenCalled(); + expect(useAppStore.getState().lanes).toEqual(lanes); + }); + + it("refreshLanes can update runtime snapshots without recomputing lane git status", async () => { + useAppStore.setState({ + lanes: [{ id: "lane-1", name: "Lane 1", status: { dirty: true }, parentStatus: { dirty: false } }] as any[], + }); + const snapshots = [ + { + lane: { id: "lane-1", name: "Lane 1", status: { dirty: false }, parentStatus: null }, + runtime: { + bucket: "running", + runningCount: 1, + awaitingInputCount: 0, + endedCount: 0, + sessionCount: 1, + }, + rebaseSuggestion: null, + autoRebaseStatus: null, + conflictStatus: null, + stateSnapshot: null, + adoptableAttached: false, + }, + ] as any[]; + (window.ade.lanes.listSnapshots as any).mockResolvedValueOnce(snapshots); + + await useAppStore.getState().refreshLanes({ + includeStatus: false, + includeSnapshots: true, + includeConflictStatus: false, + includeRebaseSuggestions: false, + includeAutoRebaseStatus: false, + }); + + expect(window.ade.lanes.listSnapshots).toHaveBeenCalledWith({ + includeArchived: false, + includeStatus: false, + includeConflictStatus: false, + includeRebaseSuggestions: false, + includeAutoRebaseStatus: false, + }); + expect(useAppStore.getState().laneSnapshots[0].runtime.bucket).toBe("running"); + expect(useAppStore.getState().lanes[0].status).toEqual({ dirty: true }); + }); + it("refreshLanes can skip conflict status for cheaper warmup snapshots", async () => { (window.ade.lanes.listSnapshots as any).mockResolvedValueOnce([]); diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index ba9c3e1ab..cb73ca127 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -618,6 +618,7 @@ type AppState = { refreshProject: () => Promise; refreshLanes: (options?: { includeStatus?: boolean; + includeSnapshots?: boolean; includeConflictStatus?: boolean; includeRebaseSuggestions?: boolean; includeAutoRebaseStatus?: boolean; @@ -632,6 +633,7 @@ export type LaneInspectorTab = "terminals" | "context" | "stack" | "merge"; type LaneRefreshRequest = { includeStatus: boolean; + includeSnapshots: boolean; includeConflictStatus: boolean; includeRebaseSuggestions: boolean; includeAutoRebaseStatus: boolean; @@ -647,22 +649,26 @@ let pendingLaneRefreshRequest: LaneRefreshRequest | null = null; function normalizeLaneRefreshRequest(options?: { includeStatus?: boolean; + includeSnapshots?: boolean; includeConflictStatus?: boolean; includeRebaseSuggestions?: boolean; includeAutoRebaseStatus?: boolean; }): LaneRefreshRequest { const includeStatus = options?.includeStatus ?? true; + const includeSnapshots = options?.includeSnapshots ?? includeStatus; return { includeStatus, - includeConflictStatus: includeStatus && (options?.includeConflictStatus ?? true), - includeRebaseSuggestions: includeStatus && (options?.includeRebaseSuggestions ?? true), - includeAutoRebaseStatus: includeStatus && (options?.includeAutoRebaseStatus ?? true), + includeSnapshots, + includeConflictStatus: includeSnapshots && (options?.includeConflictStatus ?? true), + includeRebaseSuggestions: includeSnapshots && (options?.includeRebaseSuggestions ?? true), + includeAutoRebaseStatus: includeSnapshots && (options?.includeAutoRebaseStatus ?? true), }; } function mergeLaneRefreshRequests(current: LaneRefreshRequest, next: LaneRefreshRequest): LaneRefreshRequest { return { includeStatus: current.includeStatus || next.includeStatus, + includeSnapshots: current.includeSnapshots || next.includeSnapshots, includeConflictStatus: current.includeConflictStatus || next.includeConflictStatus, includeRebaseSuggestions: current.includeRebaseSuggestions || next.includeRebaseSuggestions, includeAutoRebaseStatus: current.includeAutoRebaseStatus || next.includeAutoRebaseStatus, @@ -951,21 +957,34 @@ export const useAppStore = create((set, get) => ({ const runRefresh = async (currentRequest: LaneRefreshRequest) => { const requestedProjectKey = normalizeProjectKey(get().project?.rootPath); const token = ++laneRefreshVersion; - const laneSnapshots = currentRequest.includeStatus + const previousLanesById = new Map(get().lanes.map((lane) => [lane.id, lane] as const)); + const previousSnapshotsById = new Map(get().laneSnapshots.map((snapshot) => [snapshot.lane.id, snapshot] as const)); + const rawLaneSnapshots = currentRequest.includeSnapshots ? await window.ade.lanes.listSnapshots({ includeArchived: false, - includeStatus: true, + includeStatus: currentRequest.includeStatus, includeConflictStatus: currentRequest.includeConflictStatus, includeRebaseSuggestions: currentRequest.includeRebaseSuggestions, includeAutoRebaseStatus: currentRequest.includeAutoRebaseStatus, }) : null; - const lanes = laneSnapshots != null + const laneSnapshots = rawLaneSnapshots?.map((snapshot) => { + if (currentRequest.includeStatus) return snapshot; + const previousLane = previousLanesById.get(snapshot.lane.id) ?? previousSnapshotsById.get(snapshot.lane.id)?.lane ?? null; + return previousLane ? { ...snapshot, lane: { ...snapshot.lane, status: previousLane.status, parentStatus: previousLane.parentStatus } } : snapshot; + }) ?? null; + const rawLanes = laneSnapshots != null ? laneSnapshots.map((snapshot) => snapshot.lane) : await window.ade.lanes.list({ includeArchived: false, - includeStatus: false, + includeStatus: currentRequest.includeStatus, }); + const lanes = currentRequest.includeStatus + ? rawLanes + : rawLanes.map((lane) => { + const previousLane = previousLanesById.get(lane.id) ?? previousSnapshotsById.get(lane.id)?.lane ?? null; + return previousLane ? { ...lane, status: previousLane.status, parentStatus: previousLane.parentStatus } : lane; + }); // Discard stale response: a newer refresh was issued while this one was in-flight if (token !== laneRefreshVersion) { return; @@ -993,9 +1012,16 @@ export const useAppStore = create((set, get) => ({ nextLaneWorkViews[scopeKey] = viewState; } } + const lanesById = new Map(lanes.map((lane) => [lane.id, lane] as const)); const nextSnapshots: LaneListSnapshot[] = laneSnapshots ?? - prev.laneSnapshots.filter((snapshot) => allowed.has(snapshot.lane.id)); + prev.laneSnapshots + .filter((snapshot) => allowed.has(snapshot.lane.id)) + .map((snapshot) => { + if (!currentRequest.includeStatus) return snapshot; + const nextLane = lanesById.get(snapshot.lane.id); + return nextLane ? { ...snapshot, lane: nextLane } : snapshot; + }); persistWorkViewState({ workViewByProject: prev.workViewByProject, laneWorkViewByScope: nextLaneWorkViews, @@ -1015,6 +1041,7 @@ export const useAppStore = create((set, get) => ({ const activeSatisfies = activeRequest != null && (activeRequest.includeStatus || !request.includeStatus) + && (activeRequest.includeSnapshots || !request.includeSnapshots) && (activeRequest.includeConflictStatus || !request.includeConflictStatus) && (activeRequest.includeRebaseSuggestions || !request.includeRebaseSuggestions) && (activeRequest.includeAutoRebaseStatus || !request.includeAutoRebaseStatus); From 413c34065e2685fa4a7d6719248f2d8b76d274c5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 19:09:59 -0400 Subject: [PATCH 6/9] ship: prepare lane for review --- .../ade-cli/src/multiProjectRpcServer.test.ts | 5 +- apps/ade-cli/src/stdioRpcDaemon.test.ts | 2 +- .../src/main/services/ipc/registerIpc.ts | 3 +- .../main/services/ipc/runtimeBridge.test.ts | 104 ++++++++++++++++++ .../localRuntimeConnectionPool.test.ts | 6 +- apps/desktop/src/preload/preload.ts | 14 +-- .../src/renderer/components/app/App.tsx | 22 ++-- .../components/app/App.workKeepAlive.test.tsx | 1 + .../components/lanes/LaneGitActionsPane.tsx | 22 ++-- .../components/lanes/LanesPage.test.ts | 10 +- .../renderer/components/lanes/LanesPage.tsx | 19 ++-- .../src/renderer/state/appStore.test.ts | 17 +-- apps/desktop/src/renderer/state/appStore.ts | 20 +++- docs/ARCHITECTURE.md | 9 +- docs/features/lanes/README.md | 29 +++-- docs/features/remote-runtime/README.md | 8 +- .../remote-runtime/internal-architecture.md | 8 +- 17 files changed, 205 insertions(+), 94 deletions(-) diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index 965083e09..1c3abec8b 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -9,8 +9,9 @@ import { ProjectScopeRegistry } from "./services/projects/projectScope"; function createRegistry() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-multi-project-rpc-")); - const projectRoot = path.join(root, "project"); - fs.mkdirSync(projectRoot, { recursive: true }); + const rawProjectRoot = path.join(root, "project"); + fs.mkdirSync(rawProjectRoot, { recursive: true }); + const projectRoot = fs.realpathSync.native(rawProjectRoot); const registry = new ProjectRegistry({ adeDir: path.join(root, "home"), projectsPath: path.join(root, "home", "projects.json"), diff --git a/apps/ade-cli/src/stdioRpcDaemon.test.ts b/apps/ade-cli/src/stdioRpcDaemon.test.ts index 55fde0a39..f1bfc7b92 100644 --- a/apps/ade-cli/src/stdioRpcDaemon.test.ts +++ b/apps/ade-cli/src/stdioRpcDaemon.test.ts @@ -185,7 +185,7 @@ describe("ade rpc --stdio daemon bridge", () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-project-")); + const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-project-"))); const socketPath = path.join(adeHome, "sock", "ade.sock"); const env = { ...process.env, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 0d5e9f2b7..51cc338f1 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1771,8 +1771,7 @@ export function registerIpc({ : getOptionalSyncService(); const localRuntimeDaemonDisabled = process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1"; const allowLocalRuntimeFallback = - process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1" || - localRuntimeDaemonDisabled; + process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1"; const buildUnavailableSyncSnapshot = (): SyncRoleSnapshot => { const now = new Date().toISOString(); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 881115a02..2e072ff90 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -22,14 +22,39 @@ const remoteCallMachineForTargetMock = vi.hoisted(() => vi.fn()); const remoteDisconnectMock = vi.hoisted(() => vi.fn()); vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp"), + getVersion: vi.fn(() => "1.0.0"), + isPackaged: false, + }, BrowserWindow: { fromWebContents: browserWindowFromWebContents, getAllWindows: browserWindowGetAllWindows, }, + clipboard: { + readImage: vi.fn(() => ({ isEmpty: () => true })), + readText: vi.fn(() => ""), + writeText: vi.fn(), + }, + desktopCapturer: { + getSources: vi.fn(async () => []), + }, + dialog: { + showOpenDialog: vi.fn(), + }, ipcMain: { handle: vi.fn((channel: string, handler: (...args: any[]) => unknown) => { ipcHandlers.set(channel, handler); }), + on: vi.fn(), + }, + nativeImage: { + createFromPath: vi.fn(() => ({ isEmpty: () => true })), + }, + shell: { + openExternal: vi.fn(), + openPath: vi.fn(), + showItemInFolder: vi.fn(), }, })); @@ -62,6 +87,7 @@ vi.mock("../git/git", () => ({ })); import { registerRuntimeBridge } from "./runtimeBridge"; +import { registerIpc } from "./registerIpc"; const target: RemoteRuntimeTarget = { id: "target-1", @@ -99,6 +125,7 @@ function localBinding(rootPath = "/repo"): OpenProjectBinding { describe("registerRuntimeBridge", () => { beforeEach(() => { + delete process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON; ipcHandlers.clear(); browserWindowFromWebContents.mockReset(); browserWindowGetAllWindows.mockReset().mockReturnValue([]); @@ -373,3 +400,80 @@ describe("registerRuntimeBridge", () => { ); }); }); + +describe("registerIpc sync bridge", () => { + beforeEach(() => { + delete process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON; + ipcHandlers.clear(); + browserWindowFromWebContents.mockReset().mockReturnValue({ id: 7 }); + }); + + it("returns an unavailable sync snapshot without probing local runtime when the daemon is disabled", async () => { + process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON = "1"; + const localRuntimeConnectionPool = { + syncStatusForRoot: vi.fn(), + callSyncForRoot: vi.fn(), + }; + registerIpc({ + getCtx: () => ({ + syncService: null, + }) as any, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/repo", displayName: "Repo" } as any, + binding: localBinding("/repo"), + }), + localRuntimeConnectionPool: localRuntimeConnectionPool as any, + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + const snapshot = await ipcHandlers.get(IPC.syncGetStatus)?.( + eventForSender(), + { includeTransferReadiness: true }, + ) as any; + + expect(localRuntimeConnectionPool.syncStatusForRoot).not.toHaveBeenCalled(); + expect(snapshot.mode).toBe("standalone"); + expect(snapshot.localDevice.metadata).toEqual({ + unavailableReason: "local_runtime_daemon_disabled", + }); + expect(snapshot.client.message).toBe("Sync service unavailable in local runtime disabled mode."); + }); + + it("drops active lane presence updates instead of probing unavailable sync services when the daemon is disabled", async () => { + process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON = "1"; + const localRuntimeConnectionPool = { + callSyncForRoot: vi.fn(), + }; + const resolveSyncService = vi.fn(async () => null); + registerIpc({ + getCtx: () => ({ + syncService: null, + }) as any, + resolveSyncService, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/repo", displayName: "Repo" } as any, + binding: localBinding("/repo"), + }), + localRuntimeConnectionPool: localRuntimeConnectionPool as any, + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.syncSetActiveLanePresence)?.( + eventForSender(), + { laneIds: ["lane-1"] }, + ), + ).resolves.toBeUndefined(); + + expect(localRuntimeConnectionPool.callSyncForRoot).not.toHaveBeenCalled(); + expect(resolveSyncService).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 7ab2270e6..dfe2d0483 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -305,7 +305,7 @@ describe("local runtime connection pool", () => { expect(fs.existsSync(tsxLoaderPath)).toBe(true); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-project-")); + const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-project-"))); const socketPath = path.join(adeHome, "sock", "ade.sock"); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, @@ -364,7 +364,7 @@ describe("local runtime connection pool", () => { expect(fs.existsSync(tsxLoaderPath)).toBe(true); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-")); + const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-"))); const socketPath = path.join(adeHome, "sock", "ade.sock"); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, @@ -450,7 +450,7 @@ describe("local runtime connection pool", () => { expect(fs.existsSync(tsxLoaderPath)).toBe(true); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-project-")); + const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-project-"))); const socketPath = path.join(adeHome, "sock", "ade.sock"); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 7522e4eca..0fdb4db4a 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1384,15 +1384,11 @@ async function pollRemoteRuntimeEvents(): Promise { let nextDelayMs: number | null = null; try { const binding = await getProjectRuntimeBinding(); - if (!binding || (binding.kind !== "remote" && binding.kind !== "local")) { - remoteRuntimeEventCursor = 0; - remoteRuntimeEventBindingKey = null; - remoteRuntimeEventGeneration = projectBindingGeneration; - remoteRuntimeEventStartedAtMs = 0; - resetRemoteRuntimeEventDedup(null); - return; - } - if (binding.kind === "local" && localRuntimeDaemonDisabled) { + if ( + !binding || + (binding.kind !== "remote" && binding.kind !== "local") || + (binding.kind === "local" && localRuntimeDaemonDisabled) + ) { remoteRuntimeEventCursor = 0; remoteRuntimeEventBindingKey = null; remoteRuntimeEventGeneration = projectBindingGeneration; diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 8cabe8a77..f821c820a 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -10,15 +10,6 @@ import { useNavigate } from "react-router-dom"; -// 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 -// the history API is not tied to a normal origin. -// Relying only on `__adeBrowserMock` breaks when the flag is not set at module-eval -// time, which can strand Cursor's embedded browser on a single path. -const usesBrowserRouter = - typeof window !== "undefined" && - (window.location.protocol === "http:" || window.location.protocol === "https:"); -const Router = usesBrowserRouter ? BrowserRouter : HashRouter; import { AppShell } from "./AppShell"; import { RunPage } from "../run/RunPage"; import { ProjectSetupPage } from "../onboarding/ProjectSetupPage"; @@ -69,6 +60,16 @@ import { getAiStatusCached } from "../../lib/aiDiscoveryCache"; import { dispatchWorkSurfaceRevealed } from "../terminals/workSurfaceVisibility"; import type { AppNavigationRequest } 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 +// the history API is not tied to a normal origin. +// Relying only on `__adeBrowserMock` breaks when the flag is not set at module-eval +// time, which can strand Cursor's embedded browser on a single path. +const usesBrowserRouter = + typeof window !== "undefined" && + (window.location.protocol === "http:" || window.location.protocol === "https:"); +const Router = usesBrowserRouter ? BrowserRouter : HashRouter; + const StartupSplashScreen = (
{/* Background glow */} @@ -331,7 +332,6 @@ function BrowserHashRouteBridge() { const navigate = useNavigate(); React.useEffect(() => { - if (!usesBrowserRouter) return; const syncHashRoute = () => { const hash = window.location.hash; if (!hash.startsWith("#/")) return; @@ -373,7 +373,7 @@ export function App() {
- + {usesBrowserRouter ? : 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 39071a70c..6cb110485 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -203,5 +203,6 @@ describe("App Work route keep-alive", () => { expect(window.location.pathname).toBe("/lanes"); expect(window.location.hash).toBe(""); }); + expect(screen.getByTestId("work-page").getAttribute("data-active")).toBe("false"); }); }); diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 42b625110..9d61ff6dd 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -717,6 +717,14 @@ export function LaneGitActionsPane({ } }; + const refreshLaneGitState = useCallback(async (targetLaneId: string | null) => { + await Promise.all([ + refreshChanges(targetLaneId), + refreshLanes({ includeStatus: true, includeSnapshots: false }), + refreshGitMeta(targetLaneId), + ]); + }, [refreshChanges, refreshGitMeta, refreshLanes]); + const refreshAll = async (options?: { fetchRemote?: boolean }, targetLaneId: string | null = laneId) => { if (targetLaneId && options?.fetchRemote) { try { @@ -725,11 +733,7 @@ export function LaneGitActionsPane({ // best effort } } - await Promise.all([ - refreshChanges(targetLaneId), - refreshLanes({ includeStatus: true, includeSnapshots: false }), - refreshGitMeta(targetLaneId), - ]); + await refreshLaneGitState(targetLaneId); if (isViewingLane(targetLaneId)) { setCommitTimelineKey((prev) => prev + 1); } @@ -847,17 +851,13 @@ export function LaneGitActionsPane({ }; const completeCommitRefresh = useCallback(async (targetLaneId: string) => { - await Promise.all([ - refreshChanges(targetLaneId), - refreshLanes({ includeStatus: true, includeSnapshots: false }), - refreshGitMeta(targetLaneId), - ]); + await refreshLaneGitState(targetLaneId); if (isViewingLane(targetLaneId)) { setCommitTimelineKey((prev) => prev + 1); setCommitMessage(""); setAmendCommit(false); } - }, [isViewingLane, refreshChanges, refreshGitMeta, refreshLanes]); + }, [isViewingLane, refreshLaneGitState]); const submitCommit = useCallback(async () => { if (!laneId || (!hasStaged && !amendCommit) || busyAction != null) return; diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts index cc87dbc0c..dacdf05de 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -238,7 +238,7 @@ describe("selectLanePrTag", () => { }); describe("shouldMountGitActionsPane", () => { - it("keeps the fullscreen Git Actions pane mounted while suppressing the hidden inline duplicate", () => { + it("mounts one Git Actions pane owner when a lane is expanded", () => { expect(shouldMountGitActionsPane({ laneId: "lane-1", expandedGitActionsLaneId: "lane-1", @@ -250,13 +250,17 @@ describe("shouldMountGitActionsPane", () => { expandedGitActionsLaneId: "lane-1", surface: "git-actions-fullscreen", })).toBe(true); - }); - it("keeps inline Git Actions mounted for lanes that are not expanded", () => { expect(shouldMountGitActionsPane({ laneId: "lane-2", expandedGitActionsLaneId: "lane-1", surface: "inline", })).toBe(true); + + expect(shouldMountGitActionsPane({ + laneId: "lane-1", + expandedGitActionsLaneId: null, + surface: "inline", + })).toBe(true); }); }); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index bf6fccbe4..b8815279f 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -82,12 +82,16 @@ type RebaseScopePromptState = { type LanePaneSurface = "inline" | "git-actions-fullscreen" | "lane-fullscreen"; -export function shouldMountGitActionsPane(args: { +export function shouldMountGitActionsPane({ + laneId, + expandedGitActionsLaneId, + surface, +}: { laneId: string | null; expandedGitActionsLaneId: string | null; surface: LanePaneSurface; }): boolean { - return args.surface !== "inline" || !args.laneId || args.expandedGitActionsLaneId !== args.laneId; + return surface !== "inline" || !laneId || expandedGitActionsLaneId !== laneId; } type RebasePushReviewState = { @@ -2224,8 +2228,7 @@ export function LanesPage() { /* ---- Pane configs ---- */ - const getPaneConfigs = useCallback((laneId: string | null, options?: { surface?: LanePaneSurface }) => { - const surface = options?.surface ?? "inline"; + const getPaneConfigs = useCallback((laneId: string | null, surface: LanePaneSurface = "inline") => { const laneDetail = laneId ? lanePaneDetails[laneId] ?? EMPTY_LANE_PANE_DETAIL : EMPTY_LANE_PANE_DETAIL; const laneSnapshot = laneId ? laneSnapshotByLaneId.get(laneId) ?? null : null; const pendingLinearIssueContext = @@ -3303,7 +3306,7 @@ export function LanesPage() { key={`lanes:single:${gridResetKey}`} layoutId={`lanes:tiling:${LANES_TILING_LAYOUT_VERSION}${laneTilingLayoutSuffix}:${visibleLaneIds[0]}`} tree={laneTilingTree} - panes={getPaneConfigs(visibleLaneIds[0] ?? null, { surface: "inline" })} + panes={getPaneConfigs(visibleLaneIds[0] ?? null)} className="flex-1 min-h-0" /> ) : ( @@ -3343,7 +3346,7 @@ export function LanesPage() {
@@ -3361,7 +3364,7 @@ export function LanesPage() {
@@ -3383,7 +3386,7 @@ export function LanesPage() { diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index a3ec4007c..ecf051acb 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -286,21 +286,7 @@ describe("appStore", () => { expect(useAppStore.getState().lanes).toEqual([snapshots[0].lane]); }); - it("refreshLanes can request the cheaper snapshot bootstrap path", async () => { - const lanes = [{ id: "lane-lite", name: "Lane lite" }] as any[]; - (window.ade.lanes.list as any).mockResolvedValueOnce(lanes); - - await useAppStore.getState().refreshLanes({ includeStatus: false }); - - expect(window.ade.lanes.list).toHaveBeenCalledWith({ - includeArchived: false, - includeStatus: false, - }); - expect(window.ade.lanes.listSnapshots).not.toHaveBeenCalled(); - expect(useAppStore.getState().lanes).toEqual(lanes); - }); - - it("refreshLanes preserves prior git status for statusless lane refreshes", async () => { + it("refreshLanes can request the cheaper lane-list path while preserving prior git status", async () => { useAppStore.setState({ lanes: [{ id: "lane-lite", name: "Lane lite", status: { dirty: true }, parentStatus: { ahead: 1 } }] as any[], }); @@ -312,6 +298,7 @@ describe("appStore", () => { includeArchived: false, includeStatus: false, }); + expect(window.ade.lanes.listSnapshots).not.toHaveBeenCalled(); expect(useAppStore.getState().lanes[0]).toEqual( expect.objectContaining({ id: "lane-lite", diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index cb73ca127..f4b0f92e3 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -675,6 +675,17 @@ function mergeLaneRefreshRequests(current: LaneRefreshRequest, next: LaneRefresh }; } +function withPreservedLaneStatus( + lane: LaneSummary, + previousLanesById: Map, + previousSnapshotsById: Map, +): LaneSummary { + const previousLane = previousLanesById.get(lane.id) ?? previousSnapshotsById.get(lane.id)?.lane; + return previousLane + ? { ...lane, status: previousLane.status, parentStatus: previousLane.parentStatus } + : lane; +} + function scheduleProjectHydration(get: () => AppState) { if (warmupTimer != null) { window.clearTimeout(warmupTimer); @@ -970,8 +981,8 @@ export const useAppStore = create((set, get) => ({ : null; const laneSnapshots = rawLaneSnapshots?.map((snapshot) => { if (currentRequest.includeStatus) return snapshot; - const previousLane = previousLanesById.get(snapshot.lane.id) ?? previousSnapshotsById.get(snapshot.lane.id)?.lane ?? null; - return previousLane ? { ...snapshot, lane: { ...snapshot.lane, status: previousLane.status, parentStatus: previousLane.parentStatus } } : snapshot; + const lane = withPreservedLaneStatus(snapshot.lane, previousLanesById, previousSnapshotsById); + return lane === snapshot.lane ? snapshot : { ...snapshot, lane }; }) ?? null; const rawLanes = laneSnapshots != null ? laneSnapshots.map((snapshot) => snapshot.lane) @@ -981,10 +992,7 @@ export const useAppStore = create((set, get) => ({ }); const lanes = currentRequest.includeStatus ? rawLanes - : rawLanes.map((lane) => { - const previousLane = previousLanesById.get(lane.id) ?? previousSnapshotsById.get(lane.id)?.lane ?? null; - return previousLane ? { ...lane, status: previousLane.status, parentStatus: previousLane.parentStatus } : lane; - }); + : rawLanes.map((lane) => withPreservedLaneStatus(lane, previousLanesById, previousSnapshotsById)); // Discard stale response: a newer refresh was issued while this one was in-flight if (token !== laneRefreshVersion) { return; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e0130f445..85f1ecef9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -119,7 +119,7 @@ The desktop app is a **client of the runtime**. It owns a trusted main process, | Directory | Role | |-----------|------| | `apps/desktop/src/main/` | Node process with full OS access. Hosts windows, registers IPC handlers, routes runtime-backed APIs through local/remote runtime pools, spawns the local runtime daemon when needed, and runs the legacy in-process services that have not yet been migrated to the runtime. Entry: `main.ts`. | -| `apps/desktop/src/preload/` | Typed bridge. Entry: `preload.ts`. Uses `contextBridge.exposeInMainWorld("ade", { ... })`. Runtime-backed APIs route through `LocalRuntimeConnectionPool` (local) or `RemoteConnectionPool` (SSH-bound window). | +| `apps/desktop/src/preload/` | Typed bridge. Entry: `preload.ts`. Uses `contextBridge.exposeInMainWorld("ade", { ... })`. Runtime-backed APIs route through `LocalRuntimeConnectionPool` (local) or `RemoteConnectionPool` (SSH-bound window); when `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1`, local-bound windows skip the daemon/event pump and use guarded in-process IPC fallbacks. | | `apps/desktop/src/renderer/` | React 18 SPA. No Node access, no filesystem access, no direct process/network. Everything goes through `window.ade`. Entry: `main.tsx`. | | `apps/desktop/src/shared/` | Types, IPC channel constants (`ipc.ts`), model registry (`modelRegistry.ts`), keybindings, and other DTOs. Imported by both desktop and `apps/ade-cli`. New runtime-facing types live in `shared/types/remoteRuntime.ts` and `shared/types/core.ts`. | | `apps/desktop/src/generated/` | Build-time generated code (e.g., bootstrap SQL snapshots). | @@ -130,7 +130,7 @@ The desktop app is a **client of the runtime**. It owns a trusted main process, **Runtime binding pools.** -- `apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts` — desktop-side client for the local `ade serve` daemon. Spawns or attaches to the machine socket, registers local projects with `projects.add`, dispatches local runtime actions, and best-effort installs the background service in packaged builds. +- `apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts` — desktop-side client for the local `ade serve` daemon. Spawns or attaches to the machine socket, registers local projects with `projects.add`, dispatches local runtime actions, and best-effort installs the background service in packaged builds. `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1` is a development/diagnostic escape hatch: preload does not pump local runtime events or issue local runtime actions, and main-process sync IPC returns a standalone unavailable snapshot or no-ops lane-presence updates instead of spawning the daemon. - `apps/desktop/src/main/services/remoteRuntime/` — SSH-bound runtime pool. `remoteTargetRegistry.ts` stores saved machines under `~/.ade/secrets/remote-machines.json`; `sshTransport.ts` handles ssh-agent / key based transport; `remoteBootstrap.ts` does first-connect runtime upload + version negotiation against the bundled `ade-` binary; `remoteConnectionPool.ts` keeps the per-window remote runtime binding alive with reconnect / eviction; `runtimeRpcClient.ts` is the JSON-RPC client; `runtimeDiscovery.ts` discovers reachable runtimes on the network. Build outputs (configured in `apps/desktop/tsup.config.ts`): @@ -521,13 +521,14 @@ On startup the main process also invokes `recoverManagedOpenCodeOrphans({ force: | Pane layouts | `react-resizable-panels`, in-house `PaneTilingLayout` | | Virtualization | `@tanstack/react-virtual` | -Electron renderer runtime does **not** wrap the app in `React.StrictMode`. Browser-mock development (outside Electron) still uses Strict Mode. +Electron renderer runtime does **not** wrap the app in `React.StrictMode`. Browser-mock development (outside Electron) still uses Strict Mode. The app uses `BrowserRouter` on normal `http(s)` origins and `HashRouter` inside Electron/file-like contexts; `App.tsx` also bridges legacy `#/route` fragments into BrowserRouter paths so old ADE deep links keep working in the browser-hosted dev shell. ### 7.2 Global store -`apps/desktop/src/renderer/state/appStore.ts` (~868 lines) — Zustand store holding project, lanes, selected lane, theme, provider mode, keybindings, per-project work-view state. Patterns: +`apps/desktop/src/renderer/state/appStore.ts` (~1,325 lines) — Zustand store holding project, lanes, selected lane, theme, provider mode, keybindings, per-project work-view state. Patterns: - Narrow selectors on components to minimize re-renders. +- `refreshLanes` accepts independent lane-status and lane-snapshot flags. Callers can refresh cheap runtime snapshot decorations without recomputing git status, or update git status without rebuilding conflict/rebase/auto-rebase overlays; statusless refreshes preserve the previous `LaneStatus`/`parentStatus` in store so the UI does not flicker to unknown git state. - Per-project work-view state keyed by project root (`WorkProjectViewState`). Includes the right-edge Work sidebar fields `workSidebarOpen`, `workSidebarTab` (`"git" | "files" | "ios" | "app-control" | "browser"`), and `workSidebarWidthPct` (clamped 26–55) — persisted alongside the rest of the work-view state under `ade.workViewState.v1`. The sidebar consolidates lane-scoped tools that were previously split across separate floating panes; per-chat iOS / App Control drawers still exist on `AgentChatPane` but are suppressed when the chat is mounted as a Work tile so the sidebar owns those surfaces at lane scope. The `browser` tab is the only sidebar tab that is not lane-scoped — the built-in browser is one shared instance per app. - Store-owned event subscriptions for high-frequency streams (e.g., missions). - `projectRevision` is a monotonically incrementing counter bumped inside `setProject` whenever the active project root actually changes. Long-lived renderer-side caches (most notably the module-level xterm runtime cache in `TerminalView.tsx`) subscribe to it and tear down any entries whose `projectRoot`/`projectRevision` no longer match, so PTYs never bleed between projects. All project-transition paths (`refreshProject`, `openRepo`, `switchProjectToPath`, `closeProject`) go through `setProject` to keep the counter honest. diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 2b0842215..62d1dce70 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -24,15 +24,17 @@ remote-bound windows. The legacy in-process `laneService.ts` still exists on the desktop main process as a fallback target so older callers and tests keep working — preload calls the runtime first via `callProjectRuntimeActionOr("lane", …)` and only invokes the local IPC -handler if no runtime is bound. For remote-bound windows the worktree is -created on the remote machine; the desktop renders the same UX but the -git operations, file watchers, PTYs, and processes execute on the remote -host. The desktop main process keeps a thin `laneListSnapshotService.ts` -helper for assembling per-window lane snapshots that overlay sync -presence on top of runtime-supplied lane summaries. Multi-window: each -desktop window has its own project binding, so a lane-creation request -in window A targets window A's runtime (local or remote) regardless of -what window B is bound to. +handler if no runtime is bound. When `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1` +is set for local development/diagnostics, preload skips the local daemon +route entirely and goes straight to those in-process IPC fallbacks. For +remote-bound windows the worktree is created on the remote machine; the +desktop renders the same UX but the git operations, file watchers, PTYs, +and processes execute on the remote host. The desktop main process keeps +a thin `laneListSnapshotService.ts` helper for assembling per-window lane +snapshots that overlay sync presence on top of runtime-supplied lane +summaries. Multi-window: each desktop window has its own project binding, +so a lane-creation request in window A targets window A's runtime (local +or remote) regardless of what window B is bound to. ## Source file map @@ -67,7 +69,7 @@ Renderer components: | File | Responsibility | |------|---------------| -| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR; the row uses `selectLanePrTag` (open/draft → merged → closed, then most recent) and falls back through the same branch-equality rules as `prService.getDisplayRowForCurrentLaneBranch`, so the badge stays attached to the lane even after the PR merges. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` in `deleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar "Lane action failed" chip surfaces any failure or cancellation through `laneActionError`. | +| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR; the row uses `selectLanePrTag` (open/draft → merged → closed, then most recent) and falls back through the same branch-equality rules as `prService.getDisplayRowForCurrentLaneBranch`, so the badge stays attached to the lane even after the PR merges. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` in `deleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar "Lane action failed" chip surfaces any failure or cancellation through `laneActionError`. | | `renderer/components/lanes/laneUtils.ts` | Pure lane list/filter helpers plus default pane trees, including the work-focused tiling tree used by parallel chat launch deep links. | | `renderer/components/lanes/laneColorPalette.ts` | Curated 12-swatch lane color palette (`LANE_COLOR_PALETTE`) plus helpers (`getLaneAccent`, `colorsInUse`, `nextAvailableColor`, `laneColorName`). The first 8 hexes form `LANE_FALLBACK_COLORS`, the legacy index-based fallback used for lanes that don't have an explicit color assigned. | | `renderer/components/lanes/LaneAccentDot.tsx` | Tiny accent dot used everywhere a lane is mentioned (lane list, tabs, PR rows, AppShell PR toasts). Resolves color via `getLaneAccent` so a lane without an explicit color falls back to a deterministic fallback hex. | @@ -75,7 +77,7 @@ Renderer components: | `renderer/components/lanes/LaneContextMenu.tsx` | Right-click menu on the lane list. Hosts the inline color swatch row that calls `lanes.updateAppearance` directly, "Reveal/Copy path", manage/adopt/open-in-Run actions, split-tab actions, and batch manage. | | `renderer/components/lanes/LaneStackPane.tsx` | Stack graph sidebar, integration source chips, canvas jump | | `renderer/components/lanes/LaneDiffPane.tsx` | Lane diff list + per-file stage/unstage/discard; file content uses shared `AdeDiffViewer` (commit comparisons read-only; working-tree file can be editable when unstaged) | -| `renderer/components/lanes/LaneGitActionsPane.tsx` | Commit, stash, fetch, sync, push, recent commits. Seeds its `autoRebaseStatus` from the `autoRebaseStatusSnapshot` prop that `LanesPage` passes from the lane list (`laneSnapshot.autoRebaseStatus`), so opening a lane does not trigger a per-lane probe. A fallback `refreshAutoRebaseStatus` runs only when the snapshot is `undefined`, after a 3.5 s delay, and only while the document is visible. | +| `renderer/components/lanes/LaneGitActionsPane.tsx` | Commit, stash, fetch, sync, push, recent commits. After commit/stash operations it refreshes changes, lane git status, and git metadata while skipping snapshot decorations (`refreshLanes({ includeStatus: true, includeSnapshots: false })`). Seeds its `autoRebaseStatus` from the `autoRebaseStatusSnapshot` prop that `LanesPage` passes from the lane list (`laneSnapshot.autoRebaseStatus`), so opening a lane does not trigger a per-lane probe. A fallback `refreshAutoRebaseStatus` runs only when the snapshot is `undefined`, after a 3.5 s delay, and only while the document is visible. | | `renderer/components/lanes/LaneWorkPane.tsx` | Terminal/chat toggle work surface | | `renderer/components/lanes/LaneRebaseBanner.tsx` | Inline banner driven by `rebaseSuggestionService` | | `renderer/components/lanes/LaneEnvInitProgress.tsx` | Env init step progress inside create dialog | @@ -413,7 +415,10 @@ open lanes; primary lanes render with a home icon. (`LaneTerminalsPanel`) and an agent chat view (`AgentChatPane`). Chat sessions inherit `cwd = lane.worktreePath`. - The Lanes page reads pane overlay data from `appStore` (`lanes`, - `refreshLanes`) and from the per-lane `useLaneWorkSessions` hook. + `laneSnapshots`, `refreshLanes`) and from the per-lane + `useLaneWorkSessions` hook. `refreshLanes` can refresh lane rows, + git status, and snapshot overlays independently; statusless refreshes + preserve the previous git status in store. - `LaneRuntimeBar` (Run page) renders lane runtime state: health dot, proxy/preview status, OAuth callback URL, active processes. It parallelizes six IPC calls and debounces via an in-flight sequence diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index ca41ee3c6..90805cf4c 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -18,7 +18,9 @@ The wire transport is the same JSON-RPC the local daemon answers. The remote-run confirmation dialog before opening a remote project, surfaces local matches with uncommitted changes. - `apps/desktop/src/preload/preload.ts` — routes runtime-backed renderer APIs to - local or remote JSON-RPC actions based on the active project binding. + local or remote JSON-RPC actions based on the active project binding. When + `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1`, local-bound windows skip local runtime + actions and event polling and use guarded Electron IPC fallbacks. - `apps/ade-cli/src/multiProjectRpcServer.ts` — runtime-level project catalog and sync methods plus project-scoped action dispatch. - `apps/ade-cli/src/services/projects/` — machine project registry and @@ -91,7 +93,7 @@ After install, the headless machine can already serve clients. Desktop ADE on a Remote project bindings route lanes, agent chat, PTYs, terminal IO, file operations, file-watch notifications, git actions, PR actions, PR queue automation, PR AI conflict-resolution sessions, PR issue-resolution launch flows, Path to Merge orchestration, AI PR summaries, issue inventory, and event streaming through the remote runtime. Agent CLI failures (Claude / Codex / Cursor / Droid not installed or not authenticated) surface as inline `AgentCliAuthCard` cards in chat; the install / login buttons open a tracked terminal in the active runtime, so a remote project runs the install or login command on the remote machine. -Local project bindings prefer the local `ade serve` daemon for the same surfaces — agent chat, session history, PTYs, terminal reads/writes, file operations and watchers, diffs, lanes, PRs, PR queues, PR issue-resolution launch flows, Path to Merge, PR AI conflict-resolution sessions, issue inventory, tests, processes, project config, and most git operations. The legacy in-process Electron services remain only as a guarded fallback while the last IPC surfaces are migrated. +Local project bindings prefer the local `ade serve` daemon for the same surfaces — agent chat, session history, PTYs, terminal reads/writes, file operations and watchers, diffs, lanes, PRs, PR queues, PR issue-resolution launch flows, Path to Merge, PR AI conflict-resolution sessions, issue inventory, tests, processes, project config, and most git operations. The legacy in-process Electron services remain only as a guarded fallback while the last IPC surfaces are migrated. Setting `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1` disables that local daemon path for development/diagnostics: preload avoids local runtime action calls and the event pump, and desktop sync IPC reports a standalone unavailable snapshot instead of starting the daemon. Memory and embedding features are disabled for remote runtimes in v1. The static remote runtime does not bundle `onnxruntime-node`. @@ -99,7 +101,7 @@ Memory and embedding features are disabled for remote runtimes in v1. The static iOS does not SSH into a machine. The phone connects to the runtime daemon's sync WebSocket advertised on the LAN or over a Tailscale tailnet. Install Tailscale on the phone and the ADE machine when they are not on the same local network. -On desktop, phone pairing and sync status are managed by the local `ade serve` daemon. The legacy in-process desktop sync host is disabled by default and can be re-enabled only for diagnostics with `ADE_ENABLE_DESKTOP_SYNC_HOST=1`. +On desktop, phone pairing and sync status are managed by the local `ade serve` daemon. If the local daemon is disabled with `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1`, sync status remains visible as a standalone unavailable snapshot and lane-presence updates no-op. The legacy in-process desktop sync host is disabled by default and can be re-enabled only for diagnostics with `ADE_ENABLE_DESKTOP_SYNC_HOST=1`. ## Troubleshooting diff --git a/docs/features/remote-runtime/internal-architecture.md b/docs/features/remote-runtime/internal-architecture.md index 3edff1d12..0c6e292b2 100644 --- a/docs/features/remote-runtime/internal-architecture.md +++ b/docs/features/remote-runtime/internal-architecture.md @@ -6,7 +6,7 @@ Remote runtime support is built on the same JSON-RPC runtime the local `ade serv `OpenProjectBinding` records the active runtime for a window: -- `kind: "local"` — actions go through `LocalRuntimeConnectionPool`, which connects to the machine socket (`~/.ade/sock/ade.sock`) and spawns `ade serve` if it is not running. +- `kind: "local"` — actions normally go through `LocalRuntimeConnectionPool`, which connects to the machine socket (`~/.ade/sock/ade.sock`) and spawns `ade serve` if it is not running. With `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1`, preload treats the local runtime route as unavailable and falls back to Electron IPC without spawning or polling the daemon. - `kind: "remote"` — actions go through `RemoteConnectionPool` keyed by `{ targetId, projectId }`. The binding is established when a project is opened. Local bindings are created from the current desktop project (the desktop calls `LocalRuntimeConnectionPool.ensureProject(rootPath)` to register the project with the daemon and capture its `projectId`). Remote bindings are created by `remoteRuntimeOpenProject` after the selected target is connected and the remote project record is confirmed. @@ -33,7 +33,7 @@ Project-scoped operations are routed through `ade/actions/call` and carry `param `ade/initialize` advertises `runtimeInfo.multiProject: true` and `capabilities.projects: true`. Clients use that to decide whether to send `projectId` per request (multi-project runtime) or treat the runtime as already bound to one project (embedded `ade code --embedded`). `validateRemoteRuntimeInitializeResult` enforces both flags on the remote side and rejects mismatched runtime versions. -Runtime event streaming uses `ade/actions/call` with `name: "stream_events"` for one-shot pulls, and `runtimeEvents.subscribe` (with `runtime/event` notifications) for live streaming. For remote bindings the desktop reconnects the SSH transport before re-subscribing, matching normal remote action behavior after disconnects. For local bindings, preload polls the local daemon through `localRuntimeStreamEvents` so daemon-owned chat, terminal, pty, lane, file-watch, process, and test events are delivered through the same renderer fanout used by remote projects. +Runtime event streaming uses `ade/actions/call` with `name: "stream_events"` for one-shot pulls, and `runtimeEvents.subscribe` (with `runtime/event` notifications) for live streaming. For remote bindings the desktop reconnects the SSH transport before re-subscribing, matching normal remote action behavior after disconnects. For local bindings, preload polls the local daemon through `localRuntimeStreamEvents` so daemon-owned chat, terminal, pty, lane, file-watch, process, and test events are delivered through the same renderer fanout used by remote projects. The local event pump is not started when `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1`. ## SSH transport @@ -72,13 +72,13 @@ Before opening a remote project, `remoteRuntimeCheckLocalWork` compares the remo The sync WebSocket host is owned by the `ade serve` daemon in normal desktop operation. `ProjectScopeRegistry.ensureSyncHost` elects the most-recently-opened registered project as the active sync host and re-elects when projects are added or removed. -Desktop sync Settings IPC first talks to the local runtime daemon for status, discovery, device registry, and PIN operations, then falls back to the legacy in-process sync service only when the daemon route is unavailable. The old desktop-host path is guarded by `ADE_ENABLE_DESKTOP_SYNC_HOST=1` for diagnostics and migration debugging. +Desktop sync Settings IPC first talks to the local runtime daemon for status, discovery, device registry, and PIN operations, then falls back to the legacy in-process sync service only when the daemon route is unavailable. When `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1`, IPC skips the daemon route; if no in-process sync service is available, status returns a standalone unavailable snapshot and `sync.setActiveLanePresence` no-ops. The old desktop-host path is guarded by `ADE_ENABLE_DESKTOP_SYNC_HOST=1` for diagnostics and migration debugging. The sync command registry labels descriptors as `runtime` or `project` scope. Project-bound hosts reject project-scoped commands that arrive without a matching `projectId`, while runtime-scoped commands operate on the daemon as a whole. This keeps mobile/controller commands explicit in the multi-project runtime. ## Local daemon routing -Local desktop windows go through the runtime binding before falling back to legacy Electron-hosted handlers. `callProjectRuntimeActionOr` and `callProjectRuntimeSyncOr` in `apps/desktop/src/preload/preload.ts` try the runtime path first and fall back to the in-process IPC only on a safe local-runtime fallback error. +Local desktop windows go through the runtime binding before falling back to legacy Electron-hosted handlers. `callProjectRuntimeActionOr` and `callProjectRuntimeSyncOr` in `apps/desktop/src/preload/preload.ts` try the runtime path first and fall back to the in-process IPC only on a safe local-runtime fallback error. The exception is `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1`: preload returns "not handled" for local runtime calls immediately, so local windows use the fallback handlers directly. The runtime path covers: From 93dbbd9707ec987df5c46e29b8cfd9df4a88ad27 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 19:26:41 -0400 Subject: [PATCH 7/9] ship: iteration 1 - address snapshot refresh review --- .../src/renderer/state/appStore.test.ts | 23 +++++++++++++++---- apps/desktop/src/renderer/state/appStore.ts | 1 - 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index ecf051acb..584b5cf55 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -407,11 +407,17 @@ describe("appStore", () => { }); }); - it("refreshLanes preserves compatible lane snapshots during lightweight refresh", async () => { + it("refreshLanes syncs retained snapshots to statusless lane metadata during lightweight refresh", async () => { useAppStore.setState({ laneSnapshots: [ { - lane: { id: "lane-1", name: "Lane 1" }, + lane: { + id: "lane-1", + name: "Lane 1", + color: "#0f172a", + status: { dirty: true }, + parentStatus: { ahead: 1 }, + }, runtime: { bucket: "running", runningCount: 1, @@ -442,13 +448,22 @@ describe("appStore", () => { }, ] as any[], }); - (window.ade.lanes.list as any).mockResolvedValueOnce([{ id: "lane-1", name: "Lane 1" }] as any[]); + (window.ade.lanes.list as any).mockResolvedValueOnce([ + { id: "lane-1", name: "Lane 1", color: "#7dd3fc" }, + ] as any[]); await useAppStore.getState().refreshLanes({ includeStatus: false }); + expect(window.ade.lanes.listSnapshots).not.toHaveBeenCalled(); expect(useAppStore.getState().laneSnapshots).toEqual([ expect.objectContaining({ - lane: expect.objectContaining({ id: "lane-1" }), + lane: expect.objectContaining({ + id: "lane-1", + color: "#7dd3fc", + status: { dirty: true }, + parentStatus: { ahead: 1 }, + }), + runtime: expect.objectContaining({ bucket: "running" }), }), ]); }); diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index f4b0f92e3..f32cc9d95 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -1026,7 +1026,6 @@ export const useAppStore = create((set, get) => ({ prev.laneSnapshots .filter((snapshot) => allowed.has(snapshot.lane.id)) .map((snapshot) => { - if (!currentRequest.includeStatus) return snapshot; const nextLane = lanesById.get(snapshot.lane.id); return nextLane ? { ...snapshot, lane: nextLane } : snapshot; }); From 768d6cd0fd9fd8839f60454eb3ff6b8184f5c8a3 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 19:41:52 -0400 Subject: [PATCH 8/9] ship: iteration 2 - address refresh review --- .../src/main/services/ipc/registerIpc.ts | 136 +++++++++--------- .../main/services/ipc/runtimeBridge.test.ts | 17 ++- .../src/renderer/state/appStore.test.ts | 1 + apps/desktop/src/renderer/state/appStore.ts | 2 +- 4 files changed, 86 insertions(+), 70 deletions(-) diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 51cc338f1..a16d40848 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1773,76 +1773,76 @@ export function registerIpc({ const allowLocalRuntimeFallback = process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1"; - const buildUnavailableSyncSnapshot = (): SyncRoleSnapshot => { - const now = new Date().toISOString(); - const platform = - process.platform === "darwin" - ? "macOS" - : process.platform === "win32" - ? "windows" - : process.platform === "linux" - ? "linux" - : "unknown"; - const localDevice: SyncDeviceRecord = { - deviceId: "local-runtime-disabled", - siteId: "local-runtime-disabled", - name: "Local desktop", - platform, - deviceType: "desktop", - createdAt: now, - updatedAt: now, - lastSeenAt: now, - lastHost: null, - lastPort: null, - tailscaleIp: null, - ipAddresses: [], - metadata: { unavailableReason: "local_runtime_daemon_disabled" }, - }; - return { - mode: "standalone", - role: "brain", - localDevice, - currentBrain: localDevice, - clusterState: null, - bootstrapToken: null, - pairingPin: null, - pairingPinConfigured: false, - pairingConnectInfo: null, - connectedPeers: [], - tailnetDiscovery: { - state: "disabled", - serviceName: "ade-sync", - servicePort: 0, - target: null, - updatedAt: null, - error: null, - stderr: null, - }, - client: { - state: "disconnected", - host: null, - port: null, - connectedAt: null, - lastSeenAt: null, - latencyMs: null, - syncLag: null, - lastRemoteDbVersion: 0, - brainDeviceId: localDevice.deviceId, - hostName: localDevice.name, - error: null, - message: "Sync service unavailable in local runtime disabled mode.", - savedDraft: null, - }, - transferReadiness: { - ready: true, - blockers: [], - survivableState: [], - }, - survivableStateText: "Sync service unavailable in local runtime disabled mode.", - blockingStateText: "", - }; + const unavailableSyncSnapshotCreatedAt = new Date().toISOString(); + const unavailableSyncPlatform = + process.platform === "darwin" + ? "macOS" + : process.platform === "win32" + ? "windows" + : process.platform === "linux" + ? "linux" + : "unknown"; + const unavailableSyncDevice: SyncDeviceRecord = { + deviceId: "local-runtime-disabled", + siteId: "local-runtime-disabled", + name: "Local desktop", + platform: unavailableSyncPlatform, + deviceType: "desktop", + createdAt: unavailableSyncSnapshotCreatedAt, + updatedAt: unavailableSyncSnapshotCreatedAt, + lastSeenAt: unavailableSyncSnapshotCreatedAt, + lastHost: null, + lastPort: null, + tailscaleIp: null, + ipAddresses: [], + metadata: { unavailableReason: "local_runtime_daemon_disabled" }, + }; + const unavailableSyncSnapshot: SyncRoleSnapshot = { + mode: "standalone", + role: "brain", + localDevice: unavailableSyncDevice, + currentBrain: unavailableSyncDevice, + clusterState: null, + bootstrapToken: null, + pairingPin: null, + pairingPinConfigured: false, + pairingConnectInfo: null, + connectedPeers: [], + tailnetDiscovery: { + state: "disabled", + serviceName: "ade-sync", + servicePort: 0, + target: null, + updatedAt: null, + error: null, + stderr: null, + }, + client: { + state: "disconnected", + host: null, + port: null, + connectedAt: null, + lastSeenAt: null, + latencyMs: null, + syncLag: null, + lastRemoteDbVersion: 0, + brainDeviceId: unavailableSyncDevice.deviceId, + hostName: unavailableSyncDevice.name, + error: null, + message: "Sync service unavailable in local runtime disabled mode.", + savedDraft: null, + }, + transferReadiness: { + ready: true, + blockers: [], + survivableState: [], + }, + survivableStateText: "Sync service unavailable in local runtime disabled mode.", + blockingStateText: "", }; + const buildUnavailableSyncSnapshot = (): SyncRoleSnapshot => unavailableSyncSnapshot; + const requireSyncService = async (): Promise> => { const service = await resolveOptionalSyncService(); if (!service) { diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 2e072ff90..7dbf8b67d 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IPC } from "../../../shared/ipc"; import type { OpenProjectBinding, @@ -408,8 +408,14 @@ describe("registerIpc sync bridge", () => { browserWindowFromWebContents.mockReset().mockReturnValue({ id: 7 }); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("returns an unavailable sync snapshot without probing local runtime when the daemon is disabled", async () => { process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON = "1"; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-11T12:00:00.000Z")); const localRuntimeConnectionPool = { syncStatusForRoot: vi.fn(), callSyncForRoot: vi.fn(), @@ -434,9 +440,18 @@ describe("registerIpc sync bridge", () => { eventForSender(), { includeTransferReadiness: true }, ) as any; + vi.setSystemTime(new Date("2026-05-11T12:00:05.000Z")); + const secondSnapshot = await ipcHandlers.get(IPC.syncGetStatus)?.( + eventForSender(), + { includeTransferReadiness: true }, + ) as any; expect(localRuntimeConnectionPool.syncStatusForRoot).not.toHaveBeenCalled(); + expect(secondSnapshot).toBe(snapshot); expect(snapshot.mode).toBe("standalone"); + expect(snapshot.localDevice.createdAt).toBe("2026-05-11T12:00:00.000Z"); + expect(secondSnapshot.localDevice.updatedAt).toBe(snapshot.localDevice.updatedAt); + expect(secondSnapshot.localDevice.lastSeenAt).toBe(snapshot.localDevice.lastSeenAt); expect(snapshot.localDevice.metadata).toEqual({ unavailableReason: "local_runtime_daemon_disabled", }); diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 584b5cf55..1d62d1ff2 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -363,6 +363,7 @@ describe("appStore", () => { }); expect(useAppStore.getState().laneSnapshots[0].runtime.bucket).toBe("running"); expect(useAppStore.getState().lanes[0].status).toEqual({ dirty: true }); + expect(useAppStore.getState().laneSnapshots[0].lane).toBe(useAppStore.getState().lanes[0]); }); it("refreshLanes can skip conflict status for cheaper warmup snapshots", async () => { diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index f32cc9d95..f3c066f8d 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -990,7 +990,7 @@ export const useAppStore = create((set, get) => ({ includeArchived: false, includeStatus: currentRequest.includeStatus, }); - const lanes = currentRequest.includeStatus + const lanes = laneSnapshots != null || currentRequest.includeStatus ? rawLanes : rawLanes.map((lane) => withPreservedLaneStatus(lane, previousLanesById, previousSnapshotsById)); // Discard stale response: a newer refresh was issued while this one was in-flight From 855566e3e1487e485b742d4ce9121539f3a970ce Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 19:55:59 -0400 Subject: [PATCH 9/9] ship: iteration 3 - refresh git action overlays --- .../renderer/components/lanes/LaneGitActionsPane.test.tsx | 6 ++++++ .../src/renderer/components/lanes/LaneGitActionsPane.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx index f4dd90dab..6c48f8c3e 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx @@ -321,6 +321,12 @@ describe("LaneGitActionsPane rescue action", () => { path: ".claude/worktrees/fix-session-auto-naming", }); }); + await waitFor(() => { + expect(mockStoreState.refreshLanes).toHaveBeenCalledWith({ + includeStatus: true, + includeSnapshots: true, + }); + }); await waitFor(() => { expect(screen.queryByText(".claude/worktrees/fix-session-auto-naming")).toBeNull(); }); diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 9d61ff6dd..3acf3a868 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -720,7 +720,7 @@ export function LaneGitActionsPane({ const refreshLaneGitState = useCallback(async (targetLaneId: string | null) => { await Promise.all([ refreshChanges(targetLaneId), - refreshLanes({ includeStatus: true, includeSnapshots: false }), + refreshLanes({ includeStatus: true, includeSnapshots: true }), refreshGitMeta(targetLaneId), ]); }, [refreshChanges, refreshGitMeta, refreshLanes]);