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]);