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
1 change: 1 addition & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ export async function createAdeRuntime(args: {
}
},
onDeleteEvent: (event) => pushEvent("runtime", { type: "lane_delete_event", event }),
onLifecycleEvent: (event) => pushEvent("runtime", { type: "lane_lifecycle_event", event }),
onLinearIssueLinked: ({ lane, issue, linkedAt }) => {
const tracker = linearIssueTrackerRef;
if (!tracker) return;
Expand Down
54 changes: 53 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,7 +3,7 @@ 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, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, resumeTerminalSession, sendChatMessage, signalTerminal, startClaudeTerminalSession, steerChatMessage, unarchiveChatSession } from "../adeApi";
import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, signalTerminal, startClaudeTerminalSession, steerChatMessage, unarchiveChatSession } from "../adeApi";
import type { AdeCodeConnection } from "../types";

const tmpPaths: string[] = [];
Expand Down Expand Up @@ -56,6 +56,58 @@ describe("listLaneDiffStats", () => {
});
});

describe("runDefaultLaneSetup", () => {
it("applies the configured default template when it still exists", async () => {
const calls: Array<{ domain: string; action: string; args: Record<string, unknown> | undefined }> = [];
const connection = {
action: async (domain: string, action: string, args?: Record<string, unknown>) => {
calls.push({ domain, action, args });
if (action === "listTemplates") return [{ id: "tpl-1", name: "Default" }];
if (action === "getDefaultTemplate") return "tpl-1";
if (action === "applyTemplate") {
return { laneId: "lane-1", steps: [], startedAt: "2026-01-01T00:00:00.000Z", overallStatus: "completed" };
}
throw new Error(`unexpected action ${action}`);
},
} as unknown as AdeCodeConnection;

const result = await runDefaultLaneSetup(connection, "lane-1");

expect(result.templateId).toBe("tpl-1");
expect(result.progress.overallStatus).toBe("completed");
expect(calls).toEqual([
{ domain: "lane", action: "listTemplates", args: undefined },
{ domain: "lane", action: "getDefaultTemplate", args: undefined },
{ domain: "lane", action: "applyTemplate", args: { laneId: "lane-1", templateId: "tpl-1" } },
]);
});

it("falls back to lane init when the saved default template is gone", async () => {
const calls: Array<{ domain: string; action: string; args: Record<string, unknown> | undefined }> = [];
const connection = {
action: async (domain: string, action: string, args?: Record<string, unknown>) => {
calls.push({ domain, action, args });
if (action === "listTemplates") return [{ id: "other", name: "Other" }];
if (action === "getDefaultTemplate") return "missing";
if (action === "initEnv") {
return { laneId: "lane-1", steps: [], startedAt: "2026-01-01T00:00:00.000Z", overallStatus: "completed" };
}
throw new Error(`unexpected action ${action}`);
},
} as unknown as AdeCodeConnection;

const result = await runDefaultLaneSetup(connection, "lane-1");

expect(result.templateId).toBeNull();
expect(result.progress.overallStatus).toBe("completed");
expect(calls).toEqual([
{ domain: "lane", action: "listTemplates", args: undefined },
{ domain: "lane", action: "getDefaultTemplate", args: undefined },
{ domain: "lane", action: "initEnv", args: { laneId: "lane-1" } },
]);
});
});

describe("getChatHistoryPage", () => {
it("calls the positional chat history page action and passes maxBytes only when set", async () => {
const calls: Array<{ domain: string; action: string; argsList: unknown[] }> = [];
Expand Down
30 changes: 29 additions & 1 deletion apps/ade-cli/src/tuiClient/adeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ import type {
AgentChatSubagentTranscriptMessage,
CodexThreadGoal,
} from "../../../desktop/src/shared/types/chat";
import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config";
import type {
AiSettingsStatus,
LaneEnvInitProgress,
LaneTemplate,
OpenCodeRuntimeSnapshot,
} from "../../../desktop/src/shared/types/config";
import type { DiffLineStats, GitBranchSummary } from "../../../desktop/src/shared/types/git";
import type { LaneSummary } from "../../../desktop/src/shared/types/lanes";
import type { PrLaneSummary } from "../../../desktop/src/shared/types/prs";
Expand All @@ -66,6 +71,29 @@ export async function listLanes(
});
}

export type DefaultLaneSetupResult = {
progress: LaneEnvInitProgress;
templateId: string | null;
};

export async function runDefaultLaneSetup(
connection: AdeCodeConnection,
laneId: string,
): Promise<DefaultLaneSetupResult> {
const [templates, defaultTemplateId] = await Promise.all([
connection.action<LaneTemplate[]>("lane", "listTemplates").catch(() => []),
connection.action<string | null>("lane", "getDefaultTemplate").catch(() => null),
]);
const trimmedTemplateId = typeof defaultTemplateId === "string" ? defaultTemplateId.trim() : "";
const templateId = trimmedTemplateId && templates.some((template) => template.id === trimmedTemplateId)
? trimmedTemplateId
: null;
const progress = templateId
? await connection.action<LaneEnvInitProgress>("lane", "applyTemplate", { laneId, templateId })
: await connection.action<LaneEnvInitProgress>("lane", "initEnv", { laneId });
return { progress, templateId };
}

export async function listGitBranches(
connection: AdeCodeConnection,
laneId: string,
Expand Down
21 changes: 19 additions & 2 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import {
resizeTerminal,
reloadClaudePlugins,
respondToInput,
runDefaultLaneSetup,
saveRuntimeTempAttachment,
sendChatMessage,
sendToTerminalSession,
Expand Down Expand Up @@ -5300,6 +5301,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
]);
}, []);

const runLaneSetupAfterCreate = useCallback((conn: AdeCodeConnection, lane: LaneSummary) => {
void runDefaultLaneSetup(conn, lane.id)
.then(({ progress }) => {
if (progress.overallStatus !== "failed") return;
const failedStep = progress.steps.find((step) => step.status === "failed");
const detail = failedStep?.error?.trim()
|| (failedStep ? `${failedStep.label} failed.` : "Environment setup failed.");
addNotice(`Lane setup failed for ${lane.name}: ${detail}`, "error");
})
.catch((err) => {
addNotice(`Lane setup failed for ${lane.name}: ${err instanceof Error ? err.message : String(err)}`, "error");
});
}, [addNotice]);

const activateLaneWithLastChat = useCallback((lane: LaneSummary, options: { notify?: boolean } = {}) => {
const laneSessions = displaySessions.filter((entry) => entry.laneId === lane.id);
const lastSessionId = lastChatByLaneRef.current.get(lane.id);
Expand Down Expand Up @@ -8225,6 +8240,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
setDrawerLaneId(created.id);
setSelectedDrawerLaneId(created.id);
setSelectedDrawerLaneAction(null);
runLaneSetupAfterCreate(conn, created);
return;
}
if (name === "/rename" || name === "/chat rename") {
Expand Down Expand Up @@ -8874,7 +8890,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
: result;
setRightPane({ kind: "details", title: `ADE ${domain}.${action}`, body: renderObject(body, 24) });
}
}, [activateLaneWithLastChat, activeCommandProvider, activeLane?.name, activeSession?.provider, activeSession?.sessionId, activeSession?.title, addNotice, applyDrawerChatSelection, archiveChat, archiveLane, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openChatDeleteForm, openChatRenameForm, openFeedbackForm, openForm, openLaneDeleteForm, openLaneRenameForm, openModelPicker, openNewChatSetup, openNewLaneForm, openSubagentsPane, pendingSteers, project, refreshState, renameLane, selectActiveLaneId, selectActiveSessionId, sendOrSteerChatMessage, sessions, setChatScrollOffset, subagentPaneCommandAvailable, unarchiveChat, unarchiveLane]);
}, [activateLaneWithLastChat, activeCommandProvider, activeLane?.name, activeSession?.provider, activeSession?.sessionId, activeSession?.title, addNotice, applyDrawerChatSelection, archiveChat, archiveLane, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openChatDeleteForm, openChatRenameForm, openFeedbackForm, openForm, openLaneDeleteForm, openLaneRenameForm, openModelPicker, openNewChatSetup, openNewLaneForm, openSubagentsPane, pendingSteers, project, refreshState, renameLane, runLaneSetupAfterCreate, selectActiveLaneId, selectActiveSessionId, sendOrSteerChatMessage, sessions, setChatScrollOffset, subagentPaneCommandAvailable, unarchiveChat, unarchiveLane]);

const runInlineCommand = useCallback(async (name: string, args: string) => {
if (name === "/quit") {
Expand Down Expand Up @@ -9196,6 +9212,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
lastUserOpenedPaneRef.current = null;
focusAfterDetails();
addNotice(`Created lane ${created.name}.`, "success");
runLaneSetupAfterCreate(conn, created);
await refreshState();
setDrawerLaneId(created.id);
setSelectedDrawerLaneId(created.id);
Expand Down Expand Up @@ -9457,7 +9474,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
addNotice(`Feedback failed: ${message}`, "error");
}
}
}, [activeLaneId, addNotice, focusAfterDetails, lanes, refreshState, renameLane, selectActiveLaneId, selectActiveSessionId, selectFallbackChatAfterRemoval, sessions]);
}, [activeLaneId, addNotice, focusAfterDetails, lanes, refreshState, renameLane, runLaneSetupAfterCreate, selectActiveLaneId, selectActiveSessionId, selectFallbackChatAfterRemoval, sessions]);

const openLatestImage = useCallback(() => {
const target = latestOpenableImageTarget(events);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification, protocol, safeStorage, shell } from "electron";

Check warning on line 1 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'shell' is defined but never used. Allowed unused vars must match /^_/u
import { AsyncLocalStorage } from "node:async_hooks";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -2274,6 +2274,7 @@
}
},
onDeleteEvent: (event) => emitProjectEvent(projectRoot, IPC.lanesDeleteEvent, event),
onLifecycleEvent: (event) => emitProjectEvent(projectRoot, IPC.lanesLifecycleEvent, event),
onLinearIssueLinked: ({ lane, issue, linkedAt }) => {
const tracker = linearIssueTrackerRef;
if (!tracker) return;
Expand Down
46 changes: 45 additions & 1 deletion apps/desktop/src/main/services/lanes/laneService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
CreateLaneFromUnstagedArgs,
DeleteLaneArgs,
LaneDeleteEvent,
LaneLifecycleEvent,
LaneDeleteProgress,
LaneDeleteRisk,
LaneDeleteStep,
Expand Down Expand Up @@ -931,6 +932,7 @@ export function createLaneService({
onHeadChanged,
onRebaseEvent,
onDeleteEvent,
onLifecycleEvent,
onLinearIssueLinked,
onLinearIssueSessionLinked,
teardownDeps,
Expand All @@ -945,6 +947,7 @@ export function createLaneService({
onHeadChanged?: (args: { laneId: string; reason: string; preHeadSha: string | null; postHeadSha: string | null }) => void;
onRebaseEvent?: (event: RebaseRunEventPayload) => void;
onDeleteEvent?: (event: LaneDeleteEvent) => void;
onLifecycleEvent?: (event: LaneLifecycleEvent) => void;
onLinearIssueLinked?: (args: { lane: LaneSummary; issue: LaneLinearIssue; linkedAt: string }) => void | Promise<void>;
onLinearIssueSessionLinked?: (args: {
laneId: string;
Expand Down Expand Up @@ -2697,6 +2700,14 @@ export function createLaneService({
});
}

broadcastLifecycleEvent({
type: "lane-created",
laneId: summary.id,
laneName: summary.name,
color: summary.color,
lane: summary,
});

return summary;
};

Expand Down Expand Up @@ -2811,6 +2822,19 @@ export function createLaneService({
}
};

const broadcastLifecycleEvent = (event: LaneLifecycleEvent): void => {
if (!onLifecycleEvent) return;
try {
onLifecycleEvent(event);
} catch (err) {
logger.warn("lane.lifecycle.broadcast_failed", {
laneId: event.laneId,
type: event.type,
error: err instanceof Error ? err.message : String(err),
});
}
};

const cleanupLaneDatabaseRows = (laneId: string): void => {
db.run("update lanes set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]);
db.run("update lane_branch_profiles set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]);
Expand Down Expand Up @@ -3781,14 +3805,22 @@ export function createLaneService({
}
}

return toLaneSummary({
const summary = toLaneSummary({
row,
status,
parentStatus,
childCount: 0,
stackDepth: computeStackDepth({ laneId, rowsById, memo: new Map() }),
activeBranchProfile: ensureBranchProfileForRow(row)
});
broadcastLifecycleEvent({
type: "lane-created",
laneId: summary.id,
laneName: summary.name,
color: summary.color,
lane: summary,
});
return summary;
} catch (error) {
if (laneInserted) {
const persistedRow = getLaneRow(laneId);
Expand Down Expand Up @@ -4880,6 +4912,12 @@ export function createLaneService({
const now = new Date().toISOString();
db.run("update lanes set status = 'archived', archived_at = ? where id = ? and project_id = ?", [now, laneId, projectId]);
invalidateLaneListCache();
broadcastLifecycleEvent({
type: "lane-archived",
laneId,
laneName: row.name,
color: row.color,
});
},

unarchive({ laneId }: { laneId: string }): void {
Expand Down Expand Up @@ -5273,6 +5311,12 @@ export function createLaneService({

invalidateLaneListCache();
finalize(nonFatalFailures.length > 0 ? "completed_with_warnings" : "completed");
broadcastLifecycleEvent({
type: "lane-deleted",
laneId,
laneName: row.name,
color: row.color,
});
finishDeleteOperation("succeeded", {
overallStatus: progress.overallStatus,
warnings: nonFatalFailures,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ import type {
GetLaneEnvStatusArgs,
GetLaneOverlayArgs,
LaneDeleteEvent,
LaneLifecycleEvent,
LaneDeleteProgress,
LaneDeleteRisk,
LaneEnvInitProgress,
Expand Down Expand Up @@ -1159,6 +1160,7 @@ declare global {
listDeleteProgress: () => Promise<LaneDeleteProgress[]>;
getDeleteRisk: (args: { laneId: string }) => Promise<LaneDeleteRisk>;
onDeleteEvent: (cb: (ev: LaneDeleteEvent) => void) => () => void;
onLifecycleEvent: (cb: (ev: LaneLifecycleEvent) => void) => () => void;
getStackChain: (laneId: string) => Promise<StackChainItem[]>;
getChildren: (laneId: string) => Promise<LaneSummary[]>;
attachLinearIssueToSession: (args: {
Expand Down
42 changes: 42 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ import type {
GetLaneEnvStatusArgs,
GetLaneOverlayArgs,
LaneDeleteEvent,
LaneLifecycleEvent,
LaneDeleteProgress,
LaneDeleteRisk,
LaneEnvInitProgress,
Expand Down Expand Up @@ -1497,6 +1498,9 @@ const remoteSessionChangedCallbacks = new Set<
const remoteLaneDeleteEventCallbacks = new Set<
(payload: LaneDeleteEvent) => void
>();
const remoteLaneLifecycleEventCallbacks = new Set<
(payload: LaneLifecycleEvent) => void
>();
const remoteLaneRebaseEventCallbacks = new Set<
(payload: RebaseRunEventPayload) => void
>();
Expand Down Expand Up @@ -1695,6 +1699,7 @@ function hasRemoteRuntimeEventSubscribers(): boolean {
remoteReviewEventCallbacks.size > 0 ||
remoteSessionChangedCallbacks.size > 0 ||
remoteLaneDeleteEventCallbacks.size > 0 ||
remoteLaneLifecycleEventCallbacks.size > 0 ||
remoteLaneRebaseEventCallbacks.size > 0 ||
remoteLaneRebaseSuggestionsEventCallbacks.size > 0 ||
remoteLaneAutoRebaseEventCallbacks.size > 0 ||
Expand Down Expand Up @@ -2210,6 +2215,21 @@ function dispatchRemoteRuntimeEventPayload(
}
}

const laneLifecycleEvent = toWrappedEvent<LaneLifecycleEvent>(
payload,
"lane_lifecycle_event",
);
if (laneLifecycleEvent) {
clearGitReadCaches();
for (const cb of [...remoteLaneLifecycleEventCallbacks]) {
try {
cb(laneLifecycleEvent);
} catch (error) {
console.error("preload remote lane lifecycle listener failed", error);
}
}
}

const laneRebaseEvent = toWrappedEvent<RebaseRunEventPayload>(
payload,
"lane_rebase_event",
Expand Down Expand Up @@ -2478,6 +2498,16 @@ function subscribeRemoteLaneDeleteEvents(
};
}

function subscribeRemoteLaneLifecycleEvents(
cb: (payload: LaneLifecycleEvent) => void,
): () => void {
remoteLaneLifecycleEventCallbacks.add(cb);
ensureRemoteRuntimeEventPump();
return () => {
remoteLaneLifecycleEventCallbacks.delete(cb);
};
}

function subscribeRemoteLaneRebaseEvents(
cb: (payload: RebaseRunEventPayload) => void,
): () => void {
Expand Down Expand Up @@ -4439,6 +4469,18 @@ contextBridge.exposeInMainWorld("ade", {
ipcRenderer.removeListener(IPC.lanesDeleteEvent, listener);
};
},
onLifecycleEvent: (cb: (ev: LaneLifecycleEvent) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
payload: LaneLifecycleEvent,
) => cb(payload);
ipcRenderer.on(IPC.lanesLifecycleEvent, listener);
const removeRemote = subscribeRemoteLaneLifecycleEvents(cb);
return () => {
removeRemote();
ipcRenderer.removeListener(IPC.lanesLifecycleEvent, listener);
};
},
getStackChain: async (laneId: string): Promise<StackChainItem[]> =>
callProjectRuntimeActionOr("lane", "getStackChain", { arg: laneId }, () =>
ipcRenderer.invoke(IPC.lanesGetStackChain, { laneId }),
Expand Down
Loading
Loading