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
142 changes: 136 additions & 6 deletions src/renderer/actions/threadLaunchActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function deferred<T>() {
const mocks = vi.hoisted(() => {
const appState = {
updateProjectDraftConfig: vi.fn<(projectId: string, config: unknown) => void>(),
view: { kind: "home" as const },
view: { kind: "home" } as { kind: string; panes?: string[]; activeGroupId?: string },
projects: [] as Project[],
threads: [] as Thread[],
provisioningWorktreeThreadIds: {} as Record<string, true>,
Expand Down Expand Up @@ -189,11 +189,14 @@ describe("startThreadFromDraft host transport", () => {
id: values.threadId ?? "local-thread",
projectId: values.projectId ?? localProject.id,
archived: false,
config: values.config ?? {},
...(values.presentationMode ? { presentationMode: values.presentationMode } : {}),
...(values.remoteServerId ? { remoteServerId: values.remoteServerId } : {}),
...(values.remoteId ? { remoteId: values.remoteId } : {}),
} as Thread;
mocks.appState.threads = [thread];
// The real createThread focuses the new thread's pane.
mocks.appState.view = { kind: "thread", panes: [thread.id] };
if (values.worktreeProvisioning) {
mocks.appState.provisioningWorktreeThreadIds[thread.id] = true;
}
Expand Down Expand Up @@ -282,11 +285,15 @@ describe("startThreadFromDraft host transport", () => {
"C:\\shared-worktrees\\feature",
"feature",
);
expect(mocks.appState.queueThreadLaunch).toHaveBeenCalledWith(
"local-thread",
"build it",
undefined,
optimisticItemId,
expect(mocks.appState.queueThreadLaunch).not.toHaveBeenCalled();
expect(mocks.bridge.startThread).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "local-thread",
prompt: "build it",
projectLocation: { kind: "windows", path: "C:\\shared-worktrees\\feature" },
userMessageItemId: optimisticItemId,
initialSize: expect.objectContaining({ cols: expect.any(Number) }),
}),
);
expect(mocks.primeWorktreeGitState).toHaveBeenCalledWith(
localProject,
Expand All @@ -299,6 +306,129 @@ describe("startThreadFromDraft host transport", () => {
);
});

it("launches inline when the thread's pane was closed during worktree provisioning", async () => {
let resolveWorktree!: (result: { path: string; changesTransferred?: boolean }) => void;
mocks.createWorktree.mockReturnValue(
new Promise((resolve) => {
resolveWorktree = resolve;
}),
);

const launch = startThreadFromDraft(localProject, {
agentKind: "codex",
config: { model: "gpt-5.6" },
prompt: "build it",
presentationMode: "gui",
worktreeBranch: "feature",
worktreeIsNewBranch: true,
});
const optimisticStartCall = mocks.appState.applyRuntimeEvent.mock.calls[0];
if (!optimisticStartCall) throw new Error("Expected an optimistic user message event");
const optimisticItemId = (optimisticStartCall[1] as { itemId?: string }).itemId;

// The user switched to another thread while the worktree was provisioning,
// so no mounted ThreadView will ever consume a queued launch.
mocks.appState.view = { kind: "thread", panes: ["another-thread"] };
resolveWorktree({ path: "C:\\shared-worktrees\\feature" });
await launch;

expect(mocks.appState.queueThreadLaunch).not.toHaveBeenCalled();
expect(mocks.bridge.startThread).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "local-thread",
prompt: "build it",
projectLocation: { kind: "windows", path: "C:\\shared-worktrees\\feature" },
userMessageItemId: optimisticItemId,
initialSize: expect.objectContaining({ cols: expect.any(Number) }),
}),
);
expect(mocks.runWorktreeSetupScript).toHaveBeenCalledWith(
localProject,
"C:\\shared-worktrees\\feature",
"pnpm install",
);
});

it("marks the thread failed when the inline launch cannot start", async () => {
mocks.bridge.startThread.mockRejectedValue(new Error("spawn failed"));
let resolveWorktree!: (result: { path: string; changesTransferred?: boolean }) => void;
mocks.createWorktree.mockReturnValue(
new Promise((resolve) => {
resolveWorktree = resolve;
}),
);

const launch = startThreadFromDraft(localProject, {
agentKind: "codex",
config: { model: "gpt-5.6" },
prompt: "build it",
presentationMode: "gui",
worktreeBranch: "feature",
worktreeIsNewBranch: true,
});
mocks.appState.view = { kind: "thread", panes: ["another-thread"] };
resolveWorktree({ path: "C:\\shared-worktrees\\feature" });
await expect(launch).rejects.toThrow("spawn failed");

expect(mocks.appState.queueThreadLaunch).not.toHaveBeenCalled();
expect(mocks.appState.applyRuntimeEvent).toHaveBeenCalledWith("local-thread", {
type: "error",
threadId: "local-thread",
message: "spawn failed",
});
expect(mocks.appState.updateThreadRuntime).toHaveBeenCalledWith("local-thread", {
status: "error",
attention: "error",
errorMessage: "spawn failed",
canResumeWithConfig: false,
});
expect(mocks.performWorktreeRemoval).not.toHaveBeenCalled();
});

it("launches a local non-worktree thread inline over the bridge", async () => {
await startThreadFromDraft(localProject, {
agentKind: "codex",
config: { model: "gpt-5.6" },
prompt: "build it",
presentationMode: "gui",
});

expect(mocks.appState.queueThreadLaunch).not.toHaveBeenCalled();
expect(mocks.bridge.startThread).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "local-thread",
prompt: "build it",
projectLocation: localProject.location,
initialSize: expect.objectContaining({ cols: expect.any(Number) }),
}),
);
});

it("marks a local non-worktree thread failed when the bridge launch fails", async () => {
mocks.bridge.startThread.mockRejectedValue(new Error("spawn failed"));

await expect(
startThreadFromDraft(localProject, {
agentKind: "codex",
config: { model: "gpt-5.6" },
prompt: "build it",
presentationMode: "gui",
}),
).rejects.toThrow("spawn failed");

expect(mocks.appState.applyRuntimeEvent).toHaveBeenCalledWith("local-thread", {
type: "error",
threadId: "local-thread",
message: "spawn failed",
});
expect(mocks.appState.updateThreadRuntime).toHaveBeenCalledWith("local-thread", {
status: "error",
attention: "error",
errorMessage: "spawn failed",
canResumeWithConfig: false,
});
});

it("shows a provisioning failure on the thread opened for a new local worktree", async () => {
mocks.createWorktree.mockRejectedValue(new Error("Branch already exists"));

Expand Down
81 changes: 63 additions & 18 deletions src/renderer/actions/threadLaunchActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import type {
ThreadConfig,
ThreadPresentationMode,
} from "@/shared/contracts";
import { resolveMcpLaunchSnapshot } from "@/shared/contracts";
import { DEFAULT_TERMINAL_SIZE, resolveMcpLaunchSnapshot } from "@/shared/contracts";
import { isHomeProject, isHomeProjectId } from "@/shared/homeScope";
import { resolveProjectLocation } from "@/shared/worktree";
import { friendlyError } from "@/shared/messages";
import { buildPromptContentBlocks } from "@/shared/promptContent";
import { titlePromptFromSegments } from "@/shared/threadTitle";
Expand Down Expand Up @@ -311,26 +312,38 @@ export async function startThreadFromDraft(
await performWorktreeRemoval(project, worktreePath, worktreeBranch);
return;
}
const message = friendlyError(error);
store.applyRuntimeEvent(pendingThread.id, {
type: "error",
threadId: pendingThread.id,
message,
});
store.updateThreadRuntime(pendingThread.id, {
status: "error",
attention: "error",
errorMessage: message,
canResumeWithConfig: false,
});
markThreadLaunchFailed(pendingThread.id, error);
throw error;
}
if (useAppStore.getState().threads.some((thread) => thread.id === pendingThread.id)) {
useAppStore.getState().setThreadWorktree(pendingThread.id, worktreePath, worktreeBranch);
}
} else {
store.setThreadWorktree(pendingThread.id, worktreePath, worktreeBranch);
store.queueThreadLaunch(pendingThread.id, prompt, segments, pendingUserMessageItemId);
// Launch inline, never via the view-consumed launch queue: a queued
// launch fires only when a mounted ThreadView consumes it, so switching
// or closing the pane while the worktree provisions would leave the
// agent silently never started. The launch must not depend on the view.
const launchThread =
useAppStore.getState().threads.find((thread) => thread.id === pendingThread.id) ??
pendingThread;
try {
await performInitialThreadLaunch({
thread: launchThread,
projectLocation: resolveProjectLocation(project.location, worktreePath),
prompt,
...(segments ? { segments } : {}),
...(pendingUserMessageItemId ? { userMessageItemId: pendingUserMessageItemId } : {}),
initialSize: DEFAULT_TERMINAL_SIZE,
});
} catch (error) {
if (!useAppStore.getState().threads.some((thread) => thread.id === pendingThread.id)) {
await performWorktreeRemoval(project, worktreePath, worktreeBranch);
return;
}
markThreadLaunchFailed(pendingThread.id, error);
throw error;
}
}
} else {
await host.startThread({
Expand Down Expand Up @@ -402,11 +415,26 @@ function threadLaunchHost(project: Project): ThreadLaunchHostTransport {

return {
setupRunsOnHost: false,
startThread: (launch) => {
startThread: async (launch) => {
const thread = createThreadRow(launch);
const store = useAppStore.getState();
store.queueThreadLaunch(thread.id, launch.prompt, launch.segments);
return Promise.resolve("started");
// Launch inline, never via the view-consumed launch queue — the launch
// must not depend on which pane is mounted (see the worktree path above).
try {
await performInitialThreadLaunch({
thread,
projectLocation: resolveProjectLocation(launch.project.location, launch.worktreePath),
prompt: launch.prompt,
...(launch.segments ? { segments: launch.segments } : {}),
...(launch.userMessageItemId ? { userMessageItemId: launch.userMessageItemId } : {}),
initialSize: DEFAULT_TERMINAL_SIZE,
});
} catch (error) {
if (useAppStore.getState().threads.some((row) => row.id === thread.id)) {
markThreadLaunchFailed(thread.id, error);
}
throw error;
}
return "started";
},
};
}
Expand Down Expand Up @@ -455,6 +483,23 @@ function createThreadRow(launch: ThreadLaunchRequest): Thread {
return thread;
}

/** Surface a failed launch on the thread row (error item + error status). */
function markThreadLaunchFailed(threadId: string, error: unknown): void {
const store = useAppStore.getState();
const message = friendlyError(error);
store.applyRuntimeEvent(threadId, {
type: "error",
threadId,
message,
});
store.updateThreadRuntime(threadId, {
status: "error",
attention: "error",
errorMessage: message,
canResumeWithConfig: false,
});
}

function appendOptimisticInitialUserMessage(
thread: Thread,
prompt: string,
Expand Down
29 changes: 21 additions & 8 deletions src/renderer/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ const {
worktreeBasePath: "",
wslWorktreeBasePath: "",
workspaces: [] as Workspace[],
mcpServers: [],
disabledBuiltInMcpServers: {},
disabledBuiltInMcpTools: {},
},
},
quickComposerSubmitListeners: quickListeners,
Expand Down Expand Up @@ -391,6 +394,7 @@ vi.mock("./state/sharedSettingsStore", () => ({
{
getState: () => ({
...sharedSettingsState.current,
pushRecentModel: () => undefined,
setThemeMode: () => undefined,
}),
},
Expand Down Expand Up @@ -893,7 +897,7 @@ describe("App", () => {
expect(useExperimentStore.getState().experiments).toEqual({});
});

it("creates and queues the thread submitted by the quick composer", async () => {
it("creates and launches the thread submitted by the quick composer", async () => {
useAppStore.persist.hasHydrated = vi.fn<() => boolean>().mockReturnValue(true);
useAppStore.persist.onHydrate = vi.fn<() => () => void>(() => () => undefined);
useAppStore.persist.onFinishHydration = vi.fn<() => () => void>(() => () => undefined);
Expand Down Expand Up @@ -927,18 +931,27 @@ describe("App", () => {
});
});

await waitFor(() => {
expect(screen.getByText("sent from overlay")).toHaveAttribute(
"data-pending-launch",
"sent from overlay",
);
});
await waitFor(() => expect(bridge.startThread).toHaveBeenCalledTimes(1));
expect(useAppStore.getState().view.kind).toBe("thread");
expect(useAppStore.getState().threads[0]).toMatchObject({
const thread = useAppStore.getState().threads[0];
expect(thread).toMatchObject({
projectId: "project-1",
agentKind: "codex",
presentationMode: "gui",
});
expect(screen.getByText("sent from overlay")).toHaveAttribute(
"data-pending-launch",
"__none__",
);
expect(bridge.startThread).toHaveBeenCalledWith(
expect.objectContaining({
threadId: thread?.id,
projectLocation: { kind: "windows", path: "C:\\repo" },
prompt: "sent from overlay",
segments: [{ kind: "text", content: "sent from overlay" }],
presentationMode: "gui",
}),
);
});

it("mirrors a remotely started thread without queueing a duplicate launch", async () => {
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/hooks/useAppHydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ export function useAppHydration(options: { runtimeOwner?: boolean } = {}) {
purgeStaleArchivedThreads(30);
});

// A user can create a thread while this request is in flight. Scope the
// response to the threads that existed when it began so an older empty
// snapshot cannot mark a fresh direct launch inactive and relaunch it.
const requestedThreadIds = new Set(useAppStore.getState().threads.map((thread) => thread.id));
const snapshotsPromise = readBridge().getThreadSnapshots();

const visibleGuiThreadIds = collectVisibleGuiThreadIds();
Expand Down Expand Up @@ -171,6 +175,7 @@ export function useAppHydration(options: { runtimeOwner?: boolean } = {}) {
selectedIds.size > 0
? snapshots.filter((snapshot) => selectedIds.has(snapshot.threadId))
: [],
requestedThreadIds,
);
});
} catch (error) {
Expand Down
Loading