Skip to content
Open
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
4 changes: 2 additions & 2 deletions apps/desktop/src/preload/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1163,7 +1163,7 @@ declare global {
reparent: (args: ReparentLaneArgs) => Promise<ReparentLaneResult>;
updateAppearance: (args: UpdateLaneAppearanceArgs) => Promise<void>;
archive: (args: ArchiveLaneArgs) => Promise<void>;
delete: (args: DeleteLaneArgs) => Promise<void>;
delete: (args: DeleteLaneArgs, pin?: OpenProjectBinding | null) => Promise<void>;
cancelDelete: (args: {
laneId: string;
}) => Promise<{ cancelled: boolean; reason?: string }>;
Expand Down Expand Up @@ -1323,7 +1323,7 @@ declare global {
modelCatalog: (args?: AgentChatModelCatalogArgs) => Promise<AgentChatModelCatalog>;
archive: (args: AgentChatArchiveArgs) => Promise<void>;
unarchive: (args: AgentChatArchiveArgs) => Promise<void>;
delete: (args: AgentChatDeleteArgs) => Promise<void>;
delete: (args: AgentChatDeleteArgs, pin?: OpenProjectBinding | null) => Promise<void>;
updateSession: (
args: AgentChatUpdateSessionArgs,
) => Promise<AgentChatSession>;
Expand Down
61 changes: 50 additions & 11 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,37 @@ async function callProjectRuntimeActionStrictOr<T>(
return localRuntime.handled ? localRuntime.result : local();
}

// Route a runtime action to an EXPLICIT project binding, bypassing the mutable
// module-level `currentProjectBinding` and the project-transition guard. The
// target runtime is addressed directly by id/projectId (remote) or rootPath
// (local), exactly as the bound helpers do — the only difference is the binding
// is supplied by the caller instead of resolved from global state. Used to pin
// in-flight work (e.g. draft-launch rollback) to the project that started it so
// a concurrent project switch cannot misroute the call to the now-active
// project. Callers only pass a pin for explicitly-targeted, intentional work,
// so the transition guard (which protects the ambiguous *active* binding) does
// not apply.
async function callPinnedRuntimeAction<T>(
pin: OpenProjectBinding,
domain: string,
action: string,
request: Omit<RemoteRuntimeActionRequest, "domain" | "action"> = {},
): Promise<T> {
if (pin.kind === "remote") {
const response = (await ipcRenderer.invoke(IPC.remoteRuntimeCallAction, {
id: pin.targetId,
projectId: pin.projectId,
request: { domain, action, ...request },
})) as RemoteRuntimeActionResult;
return response.result as T;
}
const response = (await ipcRenderer.invoke(IPC.localRuntimeCallAction, {
rootPath: pin.rootPath,
request: { domain, action, ...request },
})) as RemoteRuntimeActionResult;
return response.result as T;
}

function callPrReadRuntimeActionOr<T>(
action: string,
request: Omit<RemoteRuntimeActionRequest, "domain" | "action">,
Expand Down Expand Up @@ -4424,11 +4455,15 @@ contextBridge.exposeInMainWorld("ade", {
);
clearGitReadCaches();
},
delete: async (args: DeleteLaneArgs): Promise<void> => {
delete: async (args: DeleteLaneArgs, pin?: OpenProjectBinding | null): Promise<void> => {
clearGitReadCaches();
await callProjectRuntimeActionOr("lane", "delete", { args }, () =>
ipcRenderer.invoke(IPC.lanesDelete, args),
);
if (pin) {
await callPinnedRuntimeAction<void>(pin, "lane", "delete", { args });
} else {
await callProjectRuntimeActionOr("lane", "delete", { args }, () =>
ipcRenderer.invoke(IPC.lanesDelete, args),
);
}
clearGitReadCaches();
},
cancelDelete: async (args: {
Expand Down Expand Up @@ -5208,14 +5243,18 @@ contextBridge.exposeInMainWorld("ade", {
await ipcRenderer.invoke(IPC.agentChatUnarchive, args);
agentChatSummaryCache.clear();
},
delete: async (args: AgentChatDeleteArgs): Promise<void> => {
delete: async (args: AgentChatDeleteArgs, pin?: OpenProjectBinding | null): Promise<void> => {
agentChatSummaryCache.clear();
const runtime = await callProjectRuntimeActionIfBound<void>(
"chat",
"deleteSession",
{ args },
);
if (!runtime.handled) await ipcRenderer.invoke(IPC.agentChatDelete, args);
if (pin) {
await callPinnedRuntimeAction<void>(pin, "chat", "deleteSession", { args });
} else {
const runtime = await callProjectRuntimeActionIfBound<void>(
"chat",
"deleteSession",
{ args },
);
if (!runtime.handled) await ipcRenderer.invoke(IPC.agentChatDelete, args);
}
agentChatSummaryCache.clear();
},
updateSession: async (
Expand Down
113 changes: 110 additions & 3 deletions apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3744,8 +3744,10 @@ describe("AgentChatPane submit recovery", () => {
sessionId: "created-session",
text: "This first send will fail.",
}));
expect(deleteChat).toHaveBeenCalledWith({ sessionId: "created-session" });
expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-created", force: true });
// Rollback is pinned to the originating project's binding (null here in
// the default test store) so a concurrent project switch can't misroute it.
expect(deleteChat).toHaveBeenCalledWith({ sessionId: "created-session" }, null);
expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-created", force: true }, null);
expect(onSessionCreated).not.toHaveBeenCalled();
});
});
Expand Down Expand Up @@ -3825,13 +3827,118 @@ describe("AgentChatPane submit recovery", () => {
fireEvent.click(await screen.findByRole("button", { name: "Send" }));

await waitFor(() => {
expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-created", force: true });
expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-created", force: true }, null);
expect(deleteChat).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
expect(onSessionCreated).not.toHaveBeenCalled();
});
});

it("aborts an auto-create launch before creating a lane when the project changes mid-naming", async () => {
const { createLane, suggestLaneName, deleteLane } = installAdeMocks({ sessions: [] });
let resolveName!: (name: string) => void;
suggestLaneName.mockImplementation(() => new Promise<string>((resolve) => {
resolveName = resolve;
}));
// The originating project's binding is captured when the launch starts.
useAppStore.setState({
projectBinding: {
kind: "local",
key: "local:/tmp/project-under-test",
rootPath: "/tmp/project-under-test",
displayName: "project-under-test",
} as any,
});

renderAutoCreateDraftPane();

const modelTrigger = await screen.findByRole("button", { name: /^Select model/ });
const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4";
fireEvent.pointerDown(modelTrigger, { button: 0 });
fireEvent.click(modelTrigger);
fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i }));
await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i"));

fireEvent.click(await screen.findByRole("button", { name: "Select lane" }));
fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i }));

const textbox = await screen.findByRole("textbox");
fireEvent.change(textbox, { target: { value: "Switch projects mid-launch." } });
fireEvent.click(await screen.findByRole("button", { name: "Send" }));

await waitFor(() => expect(suggestLaneName).toHaveBeenCalledTimes(1));

// Switch the active project to a different one, then let lane naming resolve.
// selectActiveProjectRoot reads project.rootPath for local bindings, so the
// scope key (and thus the status banner) stays addressable while only the
// binding key drifts.
await act(async () => {
useAppStore.setState({
projectBinding: {
kind: "local",
key: "local:/tmp/other-project",
rootPath: "/tmp/other-project",
displayName: "other-project",
} as any,
});
resolveName("would-be-lane");
await Promise.resolve();
});

await waitFor(() => {
expect(screen.getByText(/Launch failed: Project changed/i)).toBeTruthy();
});
// The guard fired before the irreversible mutation: no lane was created in
// the now-active project, and there was nothing to roll back.
expect(createLane).not.toHaveBeenCalled();
expect(deleteLane).not.toHaveBeenCalled();
});

it("pins the rollback delete to the originating project's binding", async () => {
const binding = {
kind: "local" as const,
key: "local:/tmp/project-under-test",
rootPath: "/tmp/project-under-test",
displayName: "project-under-test",
};
const { createLane, suggestLaneName, deleteLane } = installAdeMocks({
sessions: [],
sendError: new Error("send failed"),
});
suggestLaneName.mockResolvedValue("pinned-rollback-lane");
createLane.mockResolvedValue({
id: "lane-created",
name: "pinned-rollback-lane",
laneType: "worktree",
branchRef: "refs/heads/pinned-rollback-lane",
worktreePath: "/tmp/project-under-test/pinned-rollback-lane",
parentLaneId: "lane-primary",
});
useAppStore.setState({ projectBinding: binding as any });

renderAutoCreateDraftPane();

const modelTrigger = await screen.findByRole("button", { name: /^Select model/ });
const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4";
fireEvent.pointerDown(modelTrigger, { button: 0 });
fireEvent.click(modelTrigger);
fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i }));
await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i"));

fireEvent.click(await screen.findByRole("button", { name: "Select lane" }));
fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i }));

const textbox = await screen.findByRole("textbox");
fireEvent.change(textbox, { target: { value: "Roll back to the right project." } });
fireEvent.click(await screen.findByRole("button", { name: "Send" }));

await waitFor(() => {
// Same project throughout, so the lane is created, the send fails, and the
// rollback is routed at the captured binding (not the global one).
expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-created", force: true }, binding);
});
});

it("restores the Work draft bucket after remount with text, model, and attachment refs", async () => {
installAdeMocks({ sessions: [] });
const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4";
Expand Down
Loading
Loading