Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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