Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat";
import { archiveChatSession, buildPtyContinuationLaunchFields, cancelSteerMessage, clearSessionWokeMarker, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, deriveClaudeGoalFromEvents, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, enrichChatSessionsWithLifecycle, enrichTerminalSessionsWithLifecycle, getAvailableModels, getChatHistoryPage, getMainTranscript, interruptChat, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listSessionSummaries, listTerminalSessions, messageChatSession, recoverCodexTurn, recoverTurn, requestSessionAttention, resolveUnprocessedMessage, restoreCancelledQueue, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, setSessionSettleOverride, setSessionStatusNote, settleSession, signalTerminal, snoozeSession, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession, unsettleSession, wakeSession } from "../adeApi";
import { archiveChatSession, buildPtyContinuationLaunchFields, cancelSteerMessage, clearSessionWokeMarker, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, deriveClaudeGoalFromEvents, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, enrichChatSessionsWithLifecycle, enrichTerminalSessionsWithLifecycle, getAvailableModels, getChatHistoryPage, getMainTranscript, interruptChat, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listSessionSummaries, listTerminalSessions, mergeLaneStatusSnapshots, messageChatSession, recoverCodexTurn, recoverTurn, requestSessionAttention, resolveUnprocessedMessage, restoreCancelledQueue, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, setSessionSettleOverride, setSessionStatusNote, settleSession, signalTerminal, snoozeSession, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession, unsettleSession, wakeSession } from "../adeApi";
import type { ChatTerminalSession, TerminalSessionSummary } from "../../../../desktop/src/shared/types/sessions";
import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes";
import type { AdeCodeConnection } from "../types";

const tmpPaths: string[] = [];
Expand Down Expand Up @@ -34,6 +35,43 @@ function envelope(
};
}

function laneSummary(id: string, dirty: boolean): LaneSummary {
return {
id,
name: id,
laneType: "worktree",
baseRef: "main",
branchRef: id,
worktreePath: `/repo/${id}`,
parentLaneId: null,
childCount: 0,
stackDepth: 0,
parentStatus: null,
isEditProtected: false,
status: { dirty, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false },
color: null,
icon: null,
tags: [],
createdAt: "2026-01-01T00:00:00.000Z",
};
}

describe("lane status polling", () => {
it("keeps cached statuses while applying the focused lane refresh", () => {
const cheapLanes = [laneSummary("lane-1", false), laneSummary("lane-2", false), laneSummary("lane-3", false)];
const cachedLanes = [laneSummary("lane-1", true), laneSummary("lane-2", true)];
const focusedLane = {
...laneSummary("lane-1", false),
worktreeAvailable: true,
};

const merged = mergeLaneStatusSnapshots(cheapLanes, cachedLanes, focusedLane);

expect(merged.map((lane) => lane.status.dirty)).toEqual([false, true, false]);
expect(merged[0]?.worktreeAvailable).toBe(true);
});
});

describe("listLaneDiffStats", () => {
it("calls the bulk diff stats ADE action with lane ids", async () => {
const calls: Array<{ domain: string; action: string; args: Record<string, unknown> | undefined }> = [];
Expand Down
24 changes: 23 additions & 1 deletion apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ vi.mock("node:fs", async () => {
};
});

import { AdeCodeApp, BACKGROUND_REFRESH_DEBOUNCE_MS, isLaneWorktreeAvailable, MENTION_REMOTE_DEBOUNCE_MS, shouldHydrateRefreshHistory } from "../app";
import { AdeCodeApp, BACKGROUND_REFRESH_DEBOUNCE_MS, isLaneWorktreeAvailable, LANE_STATUS_REFRESH_MS, MENTION_REMOTE_DEBOUNCE_MS, shouldHydrateRefreshHistory } from "../app";

const reactActGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
let previousReactActEnvironment: boolean | undefined;
Expand Down Expand Up @@ -267,11 +267,33 @@ describe("AdeCodeApp polling", () => {
await flushAsyncEffects();

expect(mocks.listChatSessions).toHaveBeenCalledTimes(2);
expect(mocks.listLanes).toHaveBeenLastCalledWith(
connection,
{ includeStatus: false },
);
expect(connection.action).toHaveBeenCalledWith(
"lane",
"getSummary",
{ laneId: "lane-1", includeStatus: true },
);
expect(mocks.getChatHistory).toHaveBeenCalledTimes(0);

await unmountApp(instance);
});

it("refreshes every lane status on the slower cadence", async () => {
const instance = await renderApp(<AdeCodeApp project={project} />);

await act(async () => {
await vi.advanceTimersByTimeAsync(LANE_STATUS_REFRESH_MS);
});
await flushAsyncEffects();

expect(mocks.listLanes.mock.calls.filter(([, options]) => options?.includeStatus === true))
.toHaveLength(2);
await unmountApp(instance);
});

it("does not emit analytics for polling or background event streams", async () => {
const instance = await renderApp(<AdeCodeApp project={project} />);
const analyticsCalls = () => vi.mocked(connection.action).mock.calls.filter(
Expand Down
6 changes: 3 additions & 3 deletions apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ describe("ade code remote launcher", () => {

it("filters explicit paths without changing automatic LAN-first ordering", () => {
const candidates = [
{ endpoint: "ws://studio.local:8787/", kind: "lan" as const, lastSucceededAt: null },
{ endpoint: "ws://studio.example.ts.net:8787/", kind: "tailnet" as const, lastSucceededAt: null },
{ endpoint: "wss://relay.example/connect/machine", kind: "relay" as const, lastSucceededAt: null },
{ endpoint: "ws://studio.local:8787/", kind: "lan" as const, lastSucceededAt: null, recentlyFailing: false },
{ endpoint: "ws://studio.example.ts.net:8787/", kind: "tailnet" as const, lastSucceededAt: null, recentlyFailing: false },
{ endpoint: "wss://relay.example/connect/machine", kind: "relay" as const, lastSucceededAt: null, recentlyFailing: false },
];

expect(pairedEndpointCandidatesForPreference(candidates, "auto")).toEqual(candidates);
Expand Down
33 changes: 32 additions & 1 deletion apps/ade-cli/src/tuiClient/adeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,45 @@ export { buildPtyContinuationLaunchFields };

export async function listLanes(
connection: AdeCodeConnection,
options: { includeArchived?: boolean } = {},
options: { includeArchived?: boolean; includeStatus?: boolean } = {},
): Promise<LaneSummary[]> {
return await connection.action<LaneSummary[]>("lane", "list", {
includeArchived: options.includeArchived ?? false,
includeStatus: options.includeStatus ?? true,
});
}

export async function getLaneSummary(
connection: AdeCodeConnection,
laneId: string,
): Promise<LaneSummary | null> {
return await connection.action<LaneSummary | null>("lane", "getSummary", {
laneId,
includeStatus: true,
});
}

export function mergeLaneStatusSnapshots(
lanes: LaneSummary[],
cachedLanes: LaneSummary[],
refreshedLane: LaneSummary | null = null,
): LaneSummary[] {
const cachedById = new Map(cachedLanes.map((lane) => [lane.id, lane]));
return lanes.map((lane) => {
const statusSource = refreshedLane?.id === lane.id
? refreshedLane
: cachedById.get(lane.id);
if (!statusSource) return lane;
return {
...lane,
status: statusSource.status,
parentStatus: statusSource.parentStatus,
worktreeAvailable: statusSource.worktreeAvailable,
branchDrift: statusSource.branchDrift,
};
});
}

export type DefaultLaneSetupResult = {
progress: LaneEnvInitProgress;
templateId: string | null;
Expand Down
31 changes: 28 additions & 3 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
getAiSettingsStatus,
getChatHistory,
getChatHistoryPage,
getLaneSummary,
getMainTranscript,
getContextUsage,
getModelCatalog,
Expand Down Expand Up @@ -98,6 +99,7 @@ import {
listPrsByLane,
listSessionSummaries,
messageChatSession,
mergeLaneStatusSnapshots,
navigateDesktop,
newestSession,
normalizeChatTerminalSession,
Expand Down Expand Up @@ -1086,8 +1088,11 @@ type AdeCodeAppProps = {

type RefreshStateOptions = {
hydrateHistory?: boolean;
includeLaneStatus?: boolean;
};

export const LANE_STATUS_REFRESH_MS = 30_000;

export function shouldHydrateRefreshHistory(args: {
hydrateHistory?: boolean;
currentSessionId: string | null;
Expand Down Expand Up @@ -3097,6 +3102,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
// the reconnect probe below and a one-shot "reconnecting…" notice.
const [connectionLost, setConnectionLost] = useState(false);
const [lanes, setLanes] = useState<LaneSummary[]>([]);
const lanesRef = useRef<LaneSummary[]>([]);
const [prByLaneId, setPrByLaneId] = useState<Record<string, DrawerPrSummary>>({});
const [diffByLaneId, setDiffByLaneId] = useState<Record<string, DiffLineStats>>({});
const [sessions, setSessions] = useState<AgentChatSessionSummary[]>([]);
Expand Down Expand Up @@ -7352,12 +7358,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
refreshGenerationRef.current = generation;
const isCurrentRefresh = () =>
refreshGenerationRef.current === generation && connectionRef.current === conn;
const [nextLanes, listedSessions, listedTerminalSessions, sessionSummaries] = await Promise.all([
listLanes(conn),
const includeLaneStatus = options.includeLaneStatus !== false;
const activeLaneStatusPromise = includeLaneStatus || !activeLaneIdRef.current
? Promise.resolve(null)
: getLaneSummary(conn, activeLaneIdRef.current).catch(() => null);
const [listedLanes, activeLaneStatus, listedSessions, listedTerminalSessions, sessionSummaries] = await Promise.all([
listLanes(conn, { includeStatus: includeLaneStatus }),
activeLaneStatusPromise,
listChatSessions(conn),
listTerminalSessions(conn).catch(() => []),
listSessionSummaries(conn).catch(() => []),
]);
const nextLanes = includeLaneStatus
? listedLanes
: mergeLaneStatusSnapshots(listedLanes, lanesRef.current, activeLaneStatus);
const nextSessions = mergeOptimisticChatSessions(
enrichChatSessionsWithLifecycle(listedSessions, sessionSummaries),
optimisticChatSessionsRef.current,
Expand Down Expand Up @@ -7505,6 +7519,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
const activeModel = nextModels.find((model) => model.modelId === configSession?.modelId || model.id === configSession?.modelId)
?? nextModels.find((model) => model.isDefault)
?? null;
lanesRef.current = nextLanes;
setLanes(nextLanes);
sessionsRef.current = nextSessions;
setSessions(nextSessions);
Expand Down Expand Up @@ -8384,13 +8399,23 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
if (!connection) return;
const intervalMs = chatRefreshPollActive ? 1_000 : 15_000;
const timer = setInterval(() => {
void refreshState({ hydrateHistory: false }).catch((err) => {
void refreshState({ hydrateHistory: false, includeLaneStatus: false }).catch((err) => {
setError(err instanceof Error ? err.message : String(err));
});
}, intervalMs);
return () => clearInterval(timer);
}, [chatRefreshPollActive, connection, refreshState]);

useEffect(() => {
if (!connection) return;
const timer = setInterval(() => {
void refreshState({ hydrateHistory: false, includeLaneStatus: true }).catch((err) => {
setError(err instanceof Error ? err.message : String(err));
});
}, LANE_STATUS_REFRESH_MS);
return () => clearInterval(timer);
}, [connection, refreshState]);

useEffect(() => {
if (!connection) {
setDiffByLaneId({});
Expand Down
14 changes: 11 additions & 3 deletions apps/desktop/src/main/services/adeActions/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1636,14 +1636,15 @@ describe("runtime session actions", () => {
expect(clearWokeMarker).toHaveBeenCalledWith("session-1");
});

it("validates lane branch-drift args and forwards the resolution", async () => {
it("validates lane status and branch-drift args before forwarding them", async () => {
const getSummary = vi.fn(async () => ({ id: "lane-1", status: { dirty: true } }));
const getBranchDrift = vi.fn(async () => ({
expectedBranchRef: "ade/feature",
headBranchRef: "hotfix-auth",
}));
const resolveBranchDrift = vi.fn(async () => ({ resolution: "switch-back" }));
const runtime = {
laneService: { getBranchDrift, resolveBranchDrift },
laneService: { getSummary, getBranchDrift, resolveBranchDrift },
} as unknown as Parameters<typeof getAdeActionDomainServices>[0];
const laneActions = getAdeActionDomainServices(runtime).lane as Record<
string,
Expand All @@ -1652,9 +1653,16 @@ describe("runtime session actions", () => {

// `ade lane actions --text` reads this list, so drift must appear in it.
expect(listAllowedAdeActionNames("lane", laneActions)).toEqual(
expect.arrayContaining(["getBranchDrift", "resolveBranchDrift"]),
expect.arrayContaining(["getSummary", "getBranchDrift", "resolveBranchDrift"]),
);

await expect(laneActions.getSummary({ laneId: "lane-1", includeStatus: true })).resolves.toEqual({
id: "lane-1",
status: { dirty: true },
});
expect(getSummary).toHaveBeenCalledWith("lane-1", { includeStatus: true });
await expect(laneActions.getSummary({})).rejects.toThrow(/laneId/);

await expect(laneActions.getBranchDrift({ laneId: "lane-1" })).resolves.toEqual({
expectedBranchRef: "ade/feature",
headBranchRef: "hotfix-auth",
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri
"getEnvStatus",
"getOverlay",
"getStackChain",
"getSummary",
"getTemplate",
"importBranch",
"initEnv",
Expand Down Expand Up @@ -2290,6 +2291,13 @@ function buildLaneDomainService(runtime: AdeRuntime): OpaqueService {
};
return {
...laneService,
getSummary: async (args?: unknown) => {
const record = readObjectActionArg(args, "lane.getSummary");
const laneId = requireNonEmptyString(record.laneId, "laneId");
return runtime.laneService.getSummary(laneId, {
includeStatus: record.includeStatus !== false,
});
},
listSnapshots: async (args?: ListLanesArgs): Promise<LaneListSnapshot[]> => {
const lanes = await runtime.laneService.list({
includeArchived: Boolean(args?.includeArchived),
Expand Down
Loading