diff --git a/src/main/remote/RemoteAccessServer.test.ts b/src/main/remote/RemoteAccessServer.test.ts index 5fe0b038f..be087257b 100644 --- a/src/main/remote/RemoteAccessServer.test.ts +++ b/src/main/remote/RemoteAccessServer.test.ts @@ -2787,6 +2787,7 @@ describe("RemoteAccessServer", () => { config: { model: "gpt-5" }, prompt: "", presentationMode: "terminal", + userMessageItemId: "user-optimistic", }), }); @@ -2809,6 +2810,7 @@ describe("RemoteAccessServer", () => { prompt: "", initialSize: { cols: 120, rows: 30 }, presentationMode: "terminal", + userMessageItemId: "user-optimistic", ...mcpSnapshot, }), ); @@ -2818,6 +2820,7 @@ describe("RemoteAccessServer", () => { threadId: "thread-remote", projectId: "project-1", launchRuntime: false, + userMessageItemId: "user-optimistic", }), ]); }); diff --git a/src/main/remote/server/threadCommands.ts b/src/main/remote/server/threadCommands.ts index 533dcd4ea..34295dd22 100644 --- a/src/main/remote/server/threadCommands.ts +++ b/src/main/remote/server/threadCommands.ts @@ -263,6 +263,7 @@ async function startRemoteThread( ...(command.segments ? { segments: command.segments } : {}), initialSize: DEFAULT_TERMINAL_SIZE, ...(command.presentationMode ? { presentationMode: command.presentationMode } : {}), + ...(command.userMessageItemId ? { userMessageItemId: command.userMessageItemId } : {}), ...mcpSnapshot, }); } catch (error) { diff --git a/src/main/ssh/SshConnectionManager.test.ts b/src/main/ssh/SshConnectionManager.test.ts index 95473f343..fdb2f867f 100644 --- a/src/main/ssh/SshConnectionManager.test.ts +++ b/src/main/ssh/SshConnectionManager.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { PORACODE_REMOTE_PROTOCOL_VERSION } from "@/shared/remote"; import { sshConnectionConfigSchema, type SshConnectionConfig } from "@/shared/ssh"; import * as sshBootstrap from "@/shared/sshBootstrap"; import { waitForRemoteEndpoint } from "@/shared/sshBootstrap"; @@ -88,7 +89,7 @@ function createRuntimeFixture(): { function helperDescriptor(appVersion: string) { return { - protocolVersion: 1, + protocolVersion: PORACODE_REMOTE_PROTOCOL_VERSION, hostMode: "helper", desktopId: "remote-test", label: "Remote test", @@ -367,7 +368,7 @@ describe("SSH tunnel lifecycle", () => { describe("SSH helper readiness", () => { function descriptor(hostMode: "desktop" | "helper") { return { - protocolVersion: 1, + protocolVersion: PORACODE_REMOTE_PROTOCOL_VERSION, hostMode, desktopId: "remote-test", label: "Remote test", diff --git a/src/mobile/mobileSsh.test.ts b/src/mobile/mobileSsh.test.ts index 15a075fb9..6c8985015 100644 --- a/src/mobile/mobileSsh.test.ts +++ b/src/mobile/mobileSsh.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SshBridgePlugin } from "@poracode/ssh-bridge"; +import { PORACODE_REMOTE_PROTOCOL_VERSION } from "@/shared/remote"; import type { SshConnectionConfig } from "@/shared/ssh"; const bridge = vi.hoisted(() => ({ @@ -44,7 +45,7 @@ function response(input: { json?: unknown; bytes?: Uint8Array; ok?: boolean; sta function helperEnvironmentResponse() { return response({ json: { - protocolVersion: 1, + protocolVersion: PORACODE_REMOTE_PROTOCOL_VERSION, hostMode: "helper", desktopId: "remote-test", label: "Remote test", diff --git a/src/renderer/actions/threadActions.test.ts b/src/renderer/actions/threadActions.test.ts index 7e50d3289..50c6f0ec6 100644 --- a/src/renderer/actions/threadActions.test.ts +++ b/src/renderer/actions/threadActions.test.ts @@ -78,6 +78,7 @@ describe("threadActions", () => { pendingActiveThreadId: null, pendingComposerFocusThreadId: null, pendingThreadLaunches: {}, + provisioningWorktreeThreadIds: {}, runtimeItemIdsByThread: {}, runtimeItemsByIdByThread: {}, runtimeCompletedTurnsByThread: {}, @@ -607,6 +608,23 @@ describe("threadActions", () => { expect(useAppStore.getState().threads).toEqual([thread]); }); + it("deletes a provisional remote thread locally before the host row exists", () => { + const thread = makeThread({ + remoteServerId: "remote-server", + remoteId: "remote-thread-pending", + }); + useAppStore.setState({ + threads: [thread], + provisioningWorktreeThreadIds: { [thread.id]: true }, + }); + + deleteThread(thread.id); + + expect(useAppStore.getState().threads).toEqual([]); + expect(sendThreadCommand).not.toHaveBeenCalled(); + expect(bridge.closeThread).not.toHaveBeenCalled(); + }); + it("deletes a shared-worktree thread without prompting to remove the worktree", () => { const worktreePath = "/repo/.worktrees/feature"; const firstThread = makeThread({ diff --git a/src/renderer/actions/threadActions.ts b/src/renderer/actions/threadActions.ts index 46a2f0a96..7181fba08 100644 --- a/src/renderer/actions/threadActions.ts +++ b/src/renderer/actions/threadActions.ts @@ -607,6 +607,10 @@ export function acknowledgeThread(threadId: string): void { function deleteThreadOnly(threadId: string): void { const store = useAppStore.getState(); const thread = store.threads.find((candidate) => candidate.id === threadId); + if (store.provisioningWorktreeThreadIds[threadId] === true) { + store.deleteThread(threadId); + return; + } if ( thread && dispatchRemoteThreadMutation( diff --git a/src/renderer/actions/threadLaunchActions.test.ts b/src/renderer/actions/threadLaunchActions.test.ts index 62e202efb..aebe2cae9 100644 --- a/src/renderer/actions/threadLaunchActions.test.ts +++ b/src/renderer/actions/threadLaunchActions.test.ts @@ -1,5 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Project, Thread } from "@/shared/contracts"; +import type { RemoteThreadLaunchResult } from "@/renderer/state/remoteServers/types"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} const mocks = vi.hoisted(() => { const appState = { @@ -7,8 +16,23 @@ const mocks = vi.hoisted(() => { view: { kind: "home" as const }, projects: [] as Project[], threads: [] as Thread[], + provisioningWorktreeThreadIds: {} as Record, createThread: vi.fn<(input: unknown) => Thread>(), - queueThreadLaunch: vi.fn<(threadId: string, prompt: string, segments?: unknown[]) => void>(), + queueThreadLaunch: + vi.fn< + (threadId: string, prompt: string, segments?: unknown[], userMessageItemId?: string) => void + >(), + setThreadWorktree: + vi.fn< + ( + threadId: string, + worktreePath: string, + worktreeBranch?: string, + options?: { preserveProvisioning?: boolean }, + ) => void + >(), + applyRuntimeEvent: vi.fn<(threadId: string, event: unknown) => void>(), + updateThreadRuntime: vi.fn<(threadId: string, input: unknown) => void>(), setThreadMcpLaunchCustomServerNames: vi.fn<(threadId: string, names: readonly string[]) => void>(), }; @@ -22,7 +46,13 @@ const mocks = vi.hoisted(() => { transport?: { kind: "direct" } | { kind: "ssh" }; }>, runtime: {} as Record, - launchRemoteThread: vi.fn<(input: unknown) => Promise>(), + launchRemoteThread: + vi.fn< + ( + input: unknown, + options?: { isPendingLaunchOwned?: () => boolean }, + ) => Promise + >(), withClient: vi.fn< ( @@ -49,6 +79,8 @@ const mocks = vi.hoisted(() => { primeWorktreeGitState: vi.fn<(project: Project, path: string) => Promise>(), runWorktreeSetupScript: vi.fn<(project: Project, path: string, script: string) => Promise>(), + performWorktreeRemoval: + vi.fn<(project: Project, path: string, branch?: string) => Promise>(), refreshGitProject: vi.fn<(project: unknown, reason: string, scope: string) => Promise>(), generateTitleAsync: vi.fn<(...args: unknown[]) => void>(), }; @@ -114,6 +146,10 @@ vi.mock("./worktreeLaunchActions", () => ({ runWorktreeSetupScript: mocks.runWorktreeSetupScript, })); +vi.mock("./worktreeActions", () => ({ + performWorktreeRemoval: mocks.performWorktreeRemoval, +})); + import { performInitialThreadLaunch, startThreadFromDraft } from "./threadLaunchActions"; const localProject: Project = { @@ -143,17 +179,33 @@ describe("startThreadFromDraft host transport", () => { mocks.appState.view = { kind: "home" }; mocks.appState.projects = []; mocks.appState.threads = []; - mocks.appState.createThread.mockReturnValue({ - id: "local-thread", - projectId: localProject.id, - } as Thread); + mocks.appState.provisioningWorktreeThreadIds = {}; + mocks.appState.createThread.mockImplementation((input) => { + const values = input as Partial & { + threadId?: string; + worktreeProvisioning?: boolean; + }; + const thread = { + id: values.threadId ?? "local-thread", + projectId: values.projectId ?? localProject.id, + archived: false, + ...(values.presentationMode ? { presentationMode: values.presentationMode } : {}), + ...(values.remoteServerId ? { remoteServerId: values.remoteServerId } : {}), + ...(values.remoteId ? { remoteId: values.remoteId } : {}), + } as Thread; + mocks.appState.threads = [thread]; + if (values.worktreeProvisioning) { + mocks.appState.provisioningWorktreeThreadIds[thread.id] = true; + } + return thread; + }); mocks.createWorktree.mockResolvedValue({ path: "C:\\shared-worktrees\\feature", changesTransferred: true, }); mocks.remoteState.servers = []; mocks.remoteState.runtime = { d1: { status: "online" } }; - mocks.remoteState.launchRemoteThread.mockResolvedValue(undefined); + mocks.remoteState.launchRemoteThread.mockResolvedValue("started"); mocks.remoteState.withClient.mockImplementation((desktopId, invoke) => invoke(mocks.remoteClient), ); @@ -161,16 +213,63 @@ describe("startThreadFromDraft host transport", () => { mocks.bridge.startThread.mockResolvedValue({ threadId: "local-thread" }); mocks.primeWorktreeGitState.mockResolvedValue(undefined); mocks.runWorktreeSetupScript.mockResolvedValue(undefined); + mocks.performWorktreeRemoval.mockResolvedValue(true); }); - it("uses the shared transport flow for a local worktree launch", async () => { - await startThreadFromDraft(localProject, { - agentKind: "codex", - config: { model: "gpt-5.6" }, - prompt: "build it", - worktreeBranch: "feature", - worktreeIsNewBranch: true, + it("opens a local thread before its new worktree finishes 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, + }, + { replacePaneId: "draft:local-project" }, + ); + + expect(mocks.appState.createThread).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: localProject.id, + worktreeBranch: "feature", + worktreeProvisioning: true, + replacePaneId: "draft:local-project", + }), + ); + expect(mocks.appState.createThread.mock.calls[0]?.[0]).not.toHaveProperty("worktreePath"); + expect(mocks.appState.setThreadWorktree).not.toHaveBeenCalled(); + expect(mocks.appState.queueThreadLaunch).not.toHaveBeenCalled(); + expect(mocks.appState.applyRuntimeEvent).toHaveBeenCalledTimes(2); + 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; + expect(optimisticItemId).toEqual(expect.stringMatching(/^user-/)); + expect(mocks.appState.applyRuntimeEvent).toHaveBeenNthCalledWith( + 1, + "local-thread", + expect.objectContaining({ + type: "item.started", + itemId: optimisticItemId, + itemType: "user_message", + payload: { content: [{ kind: "text", text: "build it" }] }, + }), + ); + expect(mocks.appState.updateThreadRuntime).not.toHaveBeenCalled(); + + resolveWorktree({ + path: "C:\\shared-worktrees\\feature", + changesTransferred: true, }); + await launch; expect(mocks.createWorktree).toHaveBeenCalledWith(localProject, { branch: "feature", @@ -178,11 +277,16 @@ describe("startThreadFromDraft host transport", () => { keepChangesInSource: false, transferUncommitted: false, }); - expect(mocks.appState.createThread).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: localProject.id, - worktreePath: "C:\\shared-worktrees\\feature", - }), + expect(mocks.appState.setThreadWorktree).toHaveBeenCalledWith( + "local-thread", + "C:\\shared-worktrees\\feature", + "feature", + ); + expect(mocks.appState.queueThreadLaunch).toHaveBeenCalledWith( + "local-thread", + "build it", + undefined, + optimisticItemId, ); expect(mocks.primeWorktreeGitState).toHaveBeenCalledWith( localProject, @@ -195,6 +299,95 @@ describe("startThreadFromDraft host transport", () => { ); }); + it("shows a provisioning failure on the thread opened for a new local worktree", async () => { + mocks.createWorktree.mockRejectedValue(new Error("Branch already exists")); + + await expect( + startThreadFromDraft(localProject, { + agentKind: "codex", + config: { model: "gpt-5.6" }, + prompt: "build it", + worktreeBranch: "feature", + worktreeIsNewBranch: true, + }), + ).rejects.toThrow("Branch already exists"); + + expect(mocks.appState.applyRuntimeEvent).toHaveBeenCalledWith("local-thread", { + type: "error", + threadId: "local-thread", + message: "Branch already exists", + }); + expect(mocks.appState.updateThreadRuntime).toHaveBeenCalledWith("local-thread", { + status: "error", + attention: "error", + errorMessage: "Branch already exists", + canResumeWithConfig: false, + }); + expect(mocks.appState.queueThreadLaunch).not.toHaveBeenCalled(); + }); + + it("removes a new worktree if its optimistic thread was deleted while 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", + worktreeBranch: "feature", + worktreeIsNewBranch: true, + }); + mocks.appState.threads = []; + + resolveWorktree({ path: "C:\\shared-worktrees\\feature" }); + await launch; + + expect(mocks.performWorktreeRemoval).toHaveBeenCalledWith( + localProject, + "C:\\shared-worktrees\\feature", + "feature", + ); + expect(mocks.appState.setThreadWorktree).not.toHaveBeenCalled(); + expect(mocks.appState.queueThreadLaunch).not.toHaveBeenCalled(); + expect(mocks.runWorktreeSetupScript).not.toHaveBeenCalled(); + }); + + it("keeps an archived optimistic thread stopped after worktree provisioning", async () => { + mocks.appState.createThread.mockImplementation(() => { + const thread = { + id: "local-thread", + projectId: localProject.id, + archived: true, + } as Thread; + mocks.appState.threads = [thread]; + return thread; + }); + + await startThreadFromDraft(localProject, { + agentKind: "codex", + config: { model: "gpt-5.6" }, + prompt: "build it", + worktreeBranch: "feature", + worktreeIsNewBranch: true, + }); + + expect(mocks.appState.setThreadWorktree).toHaveBeenCalledWith( + "local-thread", + "C:\\shared-worktrees\\feature", + "feature", + ); + expect(mocks.appState.updateThreadRuntime).toHaveBeenCalledWith("local-thread", { + status: "inactive", + attention: "none", + canResumeWithConfig: false, + }); + expect(mocks.appState.queueThreadLaunch).not.toHaveBeenCalled(); + }); + it("launches a helper thread through the same flow and runs setup from the client", async () => { mocks.remoteState.servers = [{ desktopId: "d1", hostMode: "helper" }]; mocks.createWorktree.mockResolvedValue({ @@ -216,22 +409,178 @@ describe("startThreadFromDraft host transport", () => { keepChangesInSource: false, transferUncommitted: false, }); - expect(mocks.remoteState.launchRemoteThread).toHaveBeenCalledWith({ - desktopId: "d1", - projectId: "p1", + expect(mocks.remoteState.launchRemoteThread).toHaveBeenCalledWith( + { + threadId: expect.any(String), + desktopId: "d1", + projectId: "p1", + agentKind: "codex", + config: { model: "gpt-5.6" }, + prompt: "build it", + presentationMode: "terminal", + worktreePath: "/srv/worktrees/feature", + worktreeBranch: "feature", + isNewWorktree: true, + }, + { isPendingLaunchOwned: expect.any(Function) }, + ); + expect(mocks.runWorktreeSetupScript).toHaveBeenCalledWith( + remoteProject, + "/srv/worktrees/feature", + "pnpm install", + ); + }); + + it("shows the same optimistic GUI launch while a remote worktree is provisioning", async () => { + mocks.remoteState.servers = [{ desktopId: "d1", hostMode: "helper" }]; + let resolveWorktree!: (result: { path: string; changesTransferred?: boolean }) => void; + mocks.createWorktree.mockReturnValue( + new Promise((resolve) => { + resolveWorktree = resolve; + }), + ); + + const launch = startThreadFromDraft(remoteProject, { agentKind: "codex", config: { model: "gpt-5.6" }, - prompt: "build it", - presentationMode: "terminal", - worktreePath: "/srv/worktrees/feature", + prompt: "build remotely", + presentationMode: "gui", worktreeBranch: "feature", - isNewWorktree: true, + worktreeIsNewBranch: true, }); - expect(mocks.runWorktreeSetupScript).toHaveBeenCalledWith( + + const createInput = mocks.appState.createThread.mock.calls[0]?.[0] as + | (Partial & { threadId?: string; worktreeProvisioning?: boolean }) + | undefined; + expect(createInput).toMatchObject({ + projectId: remoteProject.id, + remoteServerId: "d1", + worktreeBranch: "feature", + worktreeProvisioning: true, + presentationMode: "gui", + }); + expect(createInput?.remoteId).toEqual(expect.any(String)); + expect(createInput?.threadId).toBe(`remote:d1:thread:${createInput?.remoteId}`); + expect(mocks.appState.applyRuntimeEvent).toHaveBeenCalledWith( + createInput?.threadId, + expect.objectContaining({ + type: "item.started", + itemType: "user_message", + payload: { content: [{ kind: "text", text: "build remotely" }] }, + }), + ); + expect(mocks.remoteState.launchRemoteThread).not.toHaveBeenCalled(); + + const optimisticItemId = ( + mocks.appState.applyRuntimeEvent.mock.calls[0]?.[1] as { itemId?: string } | undefined + )?.itemId; + resolveWorktree({ path: "/srv/worktrees/feature", changesTransferred: true }); + await launch; + + expect(mocks.appState.updateThreadRuntime).toHaveBeenCalledWith(createInput?.threadId, { + status: "working", + attention: "working", + canResumeWithConfig: false, + }); + expect(mocks.remoteState.launchRemoteThread).toHaveBeenCalledWith( + { + threadId: createInput?.remoteId, + desktopId: "d1", + projectId: "p1", + agentKind: "codex", + config: { model: "gpt-5.6" }, + prompt: "build remotely", + presentationMode: "gui", + worktreePath: "/srv/worktrees/feature", + worktreeBranch: "feature", + isNewWorktree: true, + userMessageItemId: optimisticItemId, + }, + { isPendingLaunchOwned: expect.any(Function) }, + ); + expect(mocks.appState.setThreadWorktree).toHaveBeenNthCalledWith( + 1, + createInput?.threadId, + "/srv/worktrees/feature", + "feature", + { preserveProvisioning: true }, + ); + expect(mocks.appState.setThreadWorktree).toHaveBeenLastCalledWith( + createInput?.threadId, + "/srv/worktrees/feature", + "feature", + ); + }); + + it("removes the host thread and worktree when the provisional remote row is deleted mid-start", async () => { + mocks.remoteState.servers = [{ desktopId: "d1", hostMode: "helper" }]; + const remoteStart = deferred(); + mocks.remoteState.launchRemoteThread.mockReturnValue(remoteStart.promise); + mocks.createWorktree.mockResolvedValue({ path: "/srv/worktrees/feature" }); + + const launch = startThreadFromDraft(remoteProject, { + agentKind: "codex", + config: { model: "gpt-5.6" }, + prompt: "build remotely", + worktreeBranch: "feature", + worktreeIsNewBranch: true, + }); + await vi.waitFor(() => expect(mocks.remoteState.launchRemoteThread).toHaveBeenCalledOnce()); + mocks.appState.threads = []; + remoteStart.resolve("cancelled"); + await launch; + + expect(mocks.performWorktreeRemoval).toHaveBeenCalledWith( remoteProject, "/srv/worktrees/feature", - "pnpm install", + "feature", + ); + }); + + it("retains the worktree when cancellation cannot remove the host thread", async () => { + mocks.remoteState.servers = [{ desktopId: "d1", hostMode: "helper" }]; + mocks.createWorktree.mockResolvedValue({ path: "/srv/worktrees/feature" }); + mocks.remoteState.launchRemoteThread.mockResolvedValue("cancellation-failed"); + + await startThreadFromDraft(remoteProject, { + agentKind: "codex", + config: { model: "gpt-5.6" }, + prompt: "build remotely", + worktreeBranch: "feature", + worktreeIsNewBranch: true, + }); + + expect(mocks.performWorktreeRemoval).not.toHaveBeenCalled(); + expect(mocks.primeWorktreeGitState).not.toHaveBeenCalled(); + }); + + it("retains remote worktree context when host startup fails", async () => { + mocks.remoteState.servers = [{ desktopId: "d1", hostMode: "helper" }]; + mocks.createWorktree.mockResolvedValue({ path: "/srv/worktrees/feature" }); + mocks.remoteState.launchRemoteThread.mockRejectedValue(new Error("Host refused launch")); + + await expect( + startThreadFromDraft(remoteProject, { + agentKind: "codex", + config: { model: "gpt-5.6" }, + prompt: "build remotely", + worktreeBranch: "feature", + worktreeIsNewBranch: true, + }), + ).rejects.toThrow("Host refused launch"); + + expect(mocks.appState.setThreadWorktree).toHaveBeenCalledWith( + expect.any(String), + "/srv/worktrees/feature", + "feature", + { preserveProvisioning: true }, ); + expect(mocks.appState.updateThreadRuntime).toHaveBeenLastCalledWith(expect.any(String), { + status: "error", + attention: "error", + errorMessage: "Host refused launch", + canResumeWithConfig: false, + }); }); it("refuses to launch on a remote project whose server is offline", async () => { @@ -344,4 +693,33 @@ describe("performInitialThreadLaunch host transport", () => { ); expect(mocks.remoteState.withClient).not.toHaveBeenCalled(); }); + + it("reuses an optimistic user message created before provider launch", async () => { + const thread = { + ...localThread, + presentationMode: "gui", + } as Thread; + + await performInitialThreadLaunch({ + thread, + projectLocation: localProject.location, + prompt: "build it", + userMessageItemId: "user-provisioning", + initialSize, + }); + + expect(mocks.appState.applyRuntimeEvent).not.toHaveBeenCalled(); + expect(mocks.appState.updateThreadRuntime).toHaveBeenCalledWith("local-thread", { + status: "working", + attention: "working", + canResumeWithConfig: undefined, + }); + expect(mocks.bridge.startThread).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "local-thread", + prompt: "build it", + userMessageItemId: "user-provisioning", + }), + ); + }); }); diff --git a/src/renderer/actions/threadLaunchActions.ts b/src/renderer/actions/threadLaunchActions.ts index 1cb84bf71..40b2e685f 100644 --- a/src/renderer/actions/threadLaunchActions.ts +++ b/src/renderer/actions/threadLaunchActions.ts @@ -25,9 +25,10 @@ import { findExperimentByGroupId } from "@/renderer/state/experimentStore"; import { captureFileCheckpoint } from "@/renderer/state/fileCheckpointActions"; import { refreshGitProject } from "@/renderer/state/gitRefresh"; import { unprojectProjectLocation } from "@/renderer/remoteProcedureRouter"; -import { remoteOwner } from "@/renderer/state/remoteProjection"; +import { remoteOwner, remoteThreadId } from "@/renderer/state/remoteProjection"; import { isRemoteProjectUnreachable } from "@/renderer/state/remoteServers/reachability"; import { useRemoteServersStore } from "@/renderer/state/remoteServersStore"; +import type { RemoteThreadLaunchResult } from "@/renderer/state/remoteServers/types"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { generateTitleAsync } from "@/renderer/utils/titleGen"; import { buildProjectDraftConfig } from "@/renderer/views/MainView/parts/AppContent/draftConfig"; @@ -36,15 +37,17 @@ import { primeWorktreeGitState, runWorktreeSetupScript, } from "./worktreeLaunchActions"; +import { performWorktreeRemoval } from "./worktreeActions"; export async function performInitialThreadLaunch(input: { thread: Thread; projectLocation: ProjectLocation; prompt: string; segments?: PromptSegment[]; + userMessageItemId?: string; initialSize: TerminalSize; }): Promise { - const { thread, projectLocation, prompt, segments, initialSize } = input; + const { thread, projectLocation, prompt, segments, userMessageItemId, initialSize } = input; const presentation = thread.presentationMode ?? "terminal"; if (thread.config.model) { useSharedSettings @@ -58,21 +61,9 @@ export async function performInitialThreadLaunch(input: { ); } - let optimisticUserMessageItemId: string | undefined; - if (presentation === "gui" && prompt.length > 0 && thread.sessionRef === undefined) { - optimisticUserMessageItemId = `user-${crypto.randomUUID()}`; - useAppStore.getState().applyRuntimeEvent(thread.id, { - type: "item.started", - threadId: thread.id, - itemId: optimisticUserMessageItemId, - itemType: "user_message", - payload: { content: buildPromptContentBlocks(prompt, segments) }, - }); - useAppStore.getState().applyRuntimeEvent(thread.id, { - type: "item.completed", - threadId: thread.id, - itemId: optimisticUserMessageItemId, - }); + const optimisticUserMessageItemId = + userMessageItemId ?? appendOptimisticInitialUserMessage(thread, prompt, segments); + if (optimisticUserMessageItemId) { useAppStore.getState().updateThreadRuntime(thread.id, { status: "working", attention: "working", @@ -140,20 +131,27 @@ export async function performInitialThreadLaunch(input: { } } +interface ThreadLaunchRequest { + readonly threadId?: string; + readonly remoteServerId?: string; + readonly remoteId?: string; + readonly project: Project; + readonly agentKind: string; + readonly config: ThreadConfig; + readonly prompt: string; + readonly segments?: PromptSegment[]; + readonly presentationMode?: ThreadPresentationMode; + readonly worktreePath?: string; + readonly worktreeBranch?: string; + readonly worktreeProvisioning?: boolean; + readonly userMessageItemId?: string; + readonly isNewWorktree: boolean; + readonly options: { replacePaneId?: string; preserveActiveGroup?: boolean }; +} + interface ThreadLaunchHostTransport { readonly setupRunsOnHost: boolean; - startThread(input: { - readonly project: Project; - readonly agentKind: string; - readonly config: ThreadConfig; - readonly prompt: string; - readonly segments?: PromptSegment[]; - readonly presentationMode?: ThreadPresentationMode; - readonly worktreePath?: string; - readonly worktreeBranch?: string; - readonly isNewWorktree: boolean; - readonly options: { replacePaneId?: string; preserveActiveGroup?: boolean }; - }): Promise; + startThread(input: ThreadLaunchRequest): Promise; } export async function startThreadFromDraft( @@ -184,6 +182,7 @@ export async function startThreadFromDraft( } const isHomeScope = isHomeProject(project); + const owner = remoteOwner(project); const host = threadLaunchHost(project); useAppStore.getState().updateProjectDraftConfig( @@ -197,6 +196,32 @@ export async function startThreadFromDraft( let worktreePath = isHomeScope ? undefined : existingWorktreePath; let isNewWorktree = false; + const createsWorktree = !isHomeScope && !worktreePath && !!worktreeBranch; + const remoteHostThreadId = createsWorktree && owner ? crypto.randomUUID() : undefined; + const pendingThread = createsWorktree + ? createThreadRow({ + ...(owner && remoteHostThreadId + ? { + threadId: remoteThreadId(owner.desktopId, remoteHostThreadId), + remoteServerId: owner.desktopId, + remoteId: remoteHostThreadId, + } + : {}), + project, + agentKind, + config, + prompt, + ...(segments ? { segments } : {}), + ...(presentationMode ? { presentationMode } : {}), + worktreeBranch, + worktreeProvisioning: true, + isNewWorktree: true, + options, + }) + : undefined; + const pendingUserMessageItemId = pendingThread + ? appendOptimisticInitialUserMessage(pendingThread, prompt, segments) + : undefined; if (!isHomeScope && !worktreePath && worktreeBranch) { try { const transferUncommitted = worktreeTransferUncommitted ?? false; @@ -218,23 +243,109 @@ export async function startThreadFromDraft( } } catch (error) { console.error("[renderer] failed to create worktree:", error); - toast.danger(friendlyError(error)); + const message = friendlyError(error); + if (pendingThread) { + const store = useAppStore.getState(); + store.applyRuntimeEvent(pendingThread.id, { + type: "error", + threadId: pendingThread.id, + message, + }); + store.updateThreadRuntime(pendingThread.id, { + status: "error", + attention: "error", + errorMessage: message, + canResumeWithConfig: false, + }); + } + toast.danger(message); throw error; } } - await host.startThread({ - project, - agentKind, - config, - prompt, - ...(segments ? { segments } : {}), - ...(presentationMode ? { presentationMode } : {}), - ...(worktreePath ? { worktreePath } : {}), - ...(worktreeBranch ? { worktreeBranch } : {}), - isNewWorktree, - options, - }); + if (pendingThread && worktreePath) { + const store = useAppStore.getState(); + const currentThread = store.threads.find((thread) => thread.id === pendingThread.id); + if (!currentThread) { + await performWorktreeRemoval(project, worktreePath, worktreeBranch); + return; + } + if (currentThread.archived) { + store.setThreadWorktree(pendingThread.id, worktreePath, worktreeBranch); + store.updateThreadRuntime(pendingThread.id, { + status: "inactive", + attention: "none", + canResumeWithConfig: false, + }); + } else if (owner && remoteHostThreadId) { + store.setThreadWorktree(pendingThread.id, worktreePath, worktreeBranch, { + preserveProvisioning: true, + }); + store.updateThreadRuntime(pendingThread.id, { + status: "working", + attention: "working", + canResumeWithConfig: false, + }); + try { + const started = await host.startThread({ + threadId: remoteHostThreadId, + project, + agentKind, + config, + prompt, + ...(segments ? { segments } : {}), + ...(presentationMode ? { presentationMode } : {}), + worktreePath, + ...(worktreeBranch ? { worktreeBranch } : {}), + ...(pendingUserMessageItemId ? { userMessageItemId: pendingUserMessageItemId } : {}), + isNewWorktree: true, + options, + }); + if (started === "cancelled") { + await performWorktreeRemoval(project, worktreePath, worktreeBranch); + return; + } + if (started === "cancellation-failed") return; + } catch (error) { + if (!useAppStore.getState().threads.some((thread) => thread.id === pendingThread.id)) { + 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, + }); + 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); + } + } else { + await host.startThread({ + project, + agentKind, + config, + prompt, + ...(segments ? { segments } : {}), + ...(presentationMode ? { presentationMode } : {}), + ...(worktreePath ? { worktreePath } : {}), + ...(worktreeBranch ? { worktreeBranch } : {}), + isNewWorktree, + options, + }); + } if (worktreePath) { void primeWorktreeGitState(project, worktreePath); @@ -260,18 +371,31 @@ function threadLaunchHost(project: Project): ThreadLaunchHostTransport { return { setupRunsOnHost: !helperHost, startThread: async (launch) => { - await useRemoteServersStore.getState().launchRemoteThread({ - desktopId: owner.desktopId, - projectId: owner.remoteId, - agentKind: launch.agentKind, - config: launch.config, - prompt: launch.prompt, - ...(launch.segments ? { segments: launch.segments } : {}), - presentationMode: launch.presentationMode ?? "terminal", - ...(launch.worktreePath ? { worktreePath: launch.worktreePath } : {}), - ...(launch.worktreeBranch ? { worktreeBranch: launch.worktreeBranch } : {}), - ...(launch.isNewWorktree ? { isNewWorktree: true } : {}), - }); + const remoteId = launch.threadId; + return useRemoteServersStore.getState().launchRemoteThread( + { + ...(remoteId ? { threadId: remoteId } : {}), + desktopId: owner.desktopId, + projectId: owner.remoteId, + agentKind: launch.agentKind, + config: launch.config, + prompt: launch.prompt, + ...(launch.segments ? { segments: launch.segments } : {}), + presentationMode: launch.presentationMode ?? "terminal", + ...(launch.worktreePath ? { worktreePath: launch.worktreePath } : {}), + ...(launch.worktreeBranch ? { worktreeBranch: launch.worktreeBranch } : {}), + ...(launch.isNewWorktree ? { isNewWorktree: true } : {}), + ...(launch.userMessageItemId ? { userMessageItemId: launch.userMessageItemId } : {}), + }, + remoteId + ? { + isPendingLaunchOwned: () => + useAppStore.getState().provisioningWorktreeThreadIds[ + remoteThreadId(owner.desktopId, remoteId) + ] === true, + } + : undefined, + ); }, }; } @@ -279,47 +403,80 @@ function threadLaunchHost(project: Project): ThreadLaunchHostTransport { return { setupRunsOnHost: false, startThread: (launch) => { + const thread = createThreadRow(launch); const store = useAppStore.getState(); - const { agentStatuses, wslAgentStatuses } = useAgentStatusesStore.getState(); - const projectAgentStatuses = getProjectAgentStatuses( - launch.project.location, - agentStatuses, - wslAgentStatuses, - ); - const titlePrompt = titlePromptFromSegments(launch.prompt, launch.segments); - const currentView = store.view; - const activeGroup = - launch.options.preserveActiveGroup !== false && - currentView.kind === "thread" && - currentView.activeGroupId && - !findExperimentByGroupId(currentView.activeGroupId) - ? { - groupId: currentView.activeGroupId, - groupName: store.threads.find( - (thread) => thread.groupId === currentView.activeGroupId, - )?.groupName, - } - : undefined; - - const thread = store.createThread({ - projectId: launch.project.id, - agentKind: launch.agentKind, - config: launch.config, - prompt: titlePrompt, - ...(launch.presentationMode ? { presentationMode: launch.presentationMode } : {}), - ...(launch.worktreePath - ? { - worktreePath: launch.worktreePath, - ...(launch.worktreeBranch ? { worktreeBranch: launch.worktreeBranch } : {}), - } - : {}), - ...(launch.options.replacePaneId ? { replacePaneId: launch.options.replacePaneId } : {}), - ...(activeGroup?.groupId ? { groupId: activeGroup.groupId } : {}), - ...(activeGroup?.groupName ? { groupName: activeGroup.groupName } : {}), - }); store.queueThreadLaunch(thread.id, launch.prompt, launch.segments); - generateTitleAsync(thread.id, launch.project.location, projectAgentStatuses, titlePrompt); - return Promise.resolve(); + return Promise.resolve("started"); }, }; } + +function createThreadRow(launch: ThreadLaunchRequest): Thread { + const store = useAppStore.getState(); + const { agentStatuses, wslAgentStatuses } = useAgentStatusesStore.getState(); + const projectAgentStatuses = getProjectAgentStatuses( + launch.project.location, + agentStatuses, + wslAgentStatuses, + ); + const titlePrompt = titlePromptFromSegments(launch.prompt, launch.segments); + const currentView = store.view; + const activeGroup = + launch.options.preserveActiveGroup !== false && + currentView.kind === "thread" && + currentView.activeGroupId && + !findExperimentByGroupId(currentView.activeGroupId) + ? { + groupId: currentView.activeGroupId, + groupName: store.threads.find((thread) => thread.groupId === currentView.activeGroupId) + ?.groupName, + } + : undefined; + + const thread = store.createThread({ + ...(launch.threadId ? { threadId: launch.threadId } : {}), + projectId: launch.project.id, + agentKind: launch.agentKind, + config: launch.config, + prompt: titlePrompt, + ...(launch.presentationMode ? { presentationMode: launch.presentationMode } : {}), + ...(launch.worktreePath ? { worktreePath: launch.worktreePath } : {}), + ...(launch.worktreeBranch ? { worktreeBranch: launch.worktreeBranch } : {}), + ...(launch.worktreeProvisioning ? { worktreeProvisioning: true } : {}), + ...(launch.remoteServerId ? { remoteServerId: launch.remoteServerId } : {}), + ...(launch.remoteId ? { remoteId: launch.remoteId } : {}), + ...(launch.options.replacePaneId ? { replacePaneId: launch.options.replacePaneId } : {}), + ...(activeGroup?.groupId ? { groupId: activeGroup.groupId } : {}), + ...(activeGroup?.groupName ? { groupName: activeGroup.groupName } : {}), + }); + if (!launch.remoteServerId) { + generateTitleAsync(thread.id, launch.project.location, projectAgentStatuses, titlePrompt); + } + return thread; +} + +function appendOptimisticInitialUserMessage( + thread: Thread, + prompt: string, + segments?: PromptSegment[], +): string | undefined { + const presentation = thread.presentationMode ?? "terminal"; + if (presentation !== "gui" || prompt.length === 0 || thread.sessionRef !== undefined) { + return undefined; + } + + const itemId = `user-${crypto.randomUUID()}`; + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "item.started", + threadId: thread.id, + itemId, + itemType: "user_message", + payload: { content: buildPromptContentBlocks(prompt, segments) }, + }); + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "item.completed", + threadId: thread.id, + itemId, + }); + return itemId; +} diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 5529ba236..c33c4148d 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -376,7 +376,16 @@ const mainWindowCleanups: Array<() => void> = isMainWindow ...(command.groupName ? { groupName: command.groupName } : {}), }); if (command.launchRuntime !== false) { - store.queueThreadLaunch(thread.id, command.prompt, command.segments); + if (command.userMessageItemId) { + store.queueThreadLaunch( + thread.id, + command.prompt, + command.segments, + command.userMessageItemId, + ); + } else { + store.queueThreadLaunch(thread.id, command.prompt, command.segments); + } } const { agentStatuses, wslAgentStatuses } = useAgentStatusesStore.getState(); const projectAgentStatuses = getProjectAgentStatuses( diff --git a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx index 6942aa438..a264d64b1 100644 --- a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx +++ b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx @@ -252,9 +252,37 @@ describe("ChatPane", () => { runtimeCompletedTurnsByThread: {}, fileCheckpointsByThread: {}, fileCheckpointTurnsByThread: {}, + provisioningWorktreeThreadIds: {}, })); }); + it("shows the submitted prompt while its worktree is being created", async () => { + const thread = { + ...makeThread(), + status: "launching", + worktreeBranch: "poracode/feature", + } as Thread; + useAppStore.setState({ + threads: [thread], + provisioningWorktreeThreadIds: { [thread.id]: true }, + }); + seedUserMessage(thread.id, "Build the feature"); + + const { rerender } = renderChatPane(thread); + + expect(await screen.findByText("Build the feature")).toBeInTheDocument(); + expect(screen.getByText("Creating worktree…")).toBeInTheDocument(); + + useAppStore.setState({ provisioningWorktreeThreadIds: {} }); + rerender( + + + , + ); + + expect(screen.queryByText("Creating worktree…")).not.toBeInTheDocument(); + }); + it("loads the next persisted page when LegendList reaches the start", async () => { const thread = makeThread(); seedAssistantMessage(thread.id, "Latest answer"); diff --git a/src/renderer/components/thread/ChatPane/ChatPane.tsx b/src/renderer/components/thread/ChatPane/ChatPane.tsx index 81e2272b3..ff4afddc7 100644 --- a/src/renderer/components/thread/ChatPane/ChatPane.tsx +++ b/src/renderer/components/thread/ChatPane/ChatPane.tsx @@ -32,7 +32,11 @@ import { showSubAgentPanel } from "@/renderer/actions/panelActions"; import { ChatFindBar, type ScrollToIndex } from "@/renderer/components/find/ChatFindBar"; import { ChatPaneActionsContext, type ChatPaneActions } from "./chatPaneActionsContext"; import { ChatScrollControls, type ChatScrollControlsHandle } from "./ChatScrollControls"; -import { ChatTurnElapsedFooter, type TurnTiming } from "./ChatTurnElapsed"; +import { + ChatTurnElapsedFooter, + ChatWorktreeProvisioningFooter, + type TurnTiming, +} from "./ChatTurnElapsed"; import { selectMostRecentDisplayableCompletedTurn, selectVisibleThreadTimelineEntries, @@ -286,6 +290,9 @@ export function ChatPane(props: ChatPaneProps) { const isEmpty = timelineEntries.length === 0 && !hasSupplementaryContent; const isLive = isThreadTurnActive(status); + const isWorktreeProvisioning = useAppStore( + (s) => s.provisioningWorktreeThreadIds[threadId] === true && status === "launching", + ); // Detached background work keeps the thread doing real work after the // foreground turn settles. Treat that as "still working" for the tail-loader // timer (so it keeps ticking "Working for ...") without touching `status` - @@ -390,7 +397,9 @@ export function ChatPane(props: ChatPaneProps) { ) : null } footer={ - showTailLoader && tailTurn ? ( + isWorktreeProvisioning ? ( + + ) : showTailLoader && tailTurn ? ( ) : null } @@ -447,7 +456,7 @@ export function ChatPane(props: ChatPaneProps) { layoutChangeToken={layoutChangeToken} tailEntryId={timelineEntries.at(-1)?.id ?? null} threadId={threadId} - tailLoaderVisible={showTailLoader} + tailLoaderVisible={isWorktreeProvisioning || showTailLoader} initialScrollSettled={isInitialScrollSettled} initialScrollRevealDelayMs={props.initialScrollRevealDelayMs ?? 0} virtualScrollToBottomRef={virtualScrollToBottomRef} diff --git a/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx b/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx index e24625952..92811d434 100644 --- a/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx +++ b/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx @@ -28,6 +28,30 @@ export function ChatTurnElapsedFooter({ ); } +export function ChatWorktreeProvisioningFooter() { + const { t } = useLingui(); + const textRef = useRef(null); + const text = t`Creating worktree…`; + useShimmerRef(textRef, true); + + return ( +
+ +
+ + {text} + +
+
+
+ ); +} + function WorkingFor({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) { if (turn.endedAt !== null) { return ; diff --git a/src/renderer/components/thread/TerminalThreadContent.tsx b/src/renderer/components/thread/TerminalThreadContent.tsx index b14d1eb03..9695b924a 100644 --- a/src/renderer/components/thread/TerminalThreadContent.tsx +++ b/src/renderer/components/thread/TerminalThreadContent.tsx @@ -1,4 +1,6 @@ +import { useLingui } from "@lingui/react/macro"; import { PixelLoader } from "../common/PixelLoader"; +import { useAppStore } from "@/renderer/state/appStore"; import { useThread } from "@/renderer/state/useThread"; import { TerminalPane } from "./TerminalPane"; import { ThreadComposerSection } from "./ThreadComposerSection"; @@ -24,6 +26,11 @@ export function TerminalThreadContent( }, ) { const thread = useThread(props.threadId) ?? props.fallbackThread; + const { t } = useLingui(); + const awaitingWorktree = useAppStore( + (state) => + state.provisioningWorktreeThreadIds[thread.id] === true && thread.status === "launching", + ); return ( <> @@ -43,8 +50,12 @@ export function TerminalThreadContent( )} {thread.status === "launching" || (thread.remoteServerId && !props.remoteTerminalTransport) ? ( -
+
+ {awaitingWorktree ? {t`Creating worktree…`} : null}
) : null}
diff --git a/src/renderer/components/thread/ThreadComposerSection.test.tsx b/src/renderer/components/thread/ThreadComposerSection.test.tsx index 17a37b04d..840ea6c46 100644 --- a/src/renderer/components/thread/ThreadComposerSection.test.tsx +++ b/src/renderer/components/thread/ThreadComposerSection.test.tsx @@ -3,9 +3,10 @@ import { toast } from "@heroui/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; -import type { AgentStatus, Thread } from "@/shared/contracts"; +import type { AgentStatus, GitStatusResult, Thread } from "@/shared/contracts"; import "@/renderer/components/providers/bootstrap"; import { useAppStore } from "@/renderer/state/appStore"; +import { useGitStore } from "@/renderer/state/gitStore"; import { useComposerInputInbox, worktreeComposerInboxKey, @@ -224,7 +225,9 @@ describe("ThreadComposerSection", () => { pendingSteerByThreadId: {}, pendingComposerFocusThreadId: null, threadDraftContents: {}, + provisioningWorktreeThreadIds: {}, }); + useGitStore.setState({ statuses: {} }); useComposerInputInbox.setState({ itemsByComposer: {} }); bridgeMock.isRemoteSession.mockReturnValue(false); bridgeMock.clearPendingSteer.mockClear(); @@ -245,6 +248,42 @@ describe("ThreadComposerSection", () => { toastDangerSpy.mockClear(); }); + it("hides base-checkout changes while a new worktree is provisioning", () => { + useAppStore.setState({ + provisioningWorktreeThreadIds: { [guiThread.id]: true }, + }); + useGitStore.setState({ + statuses: { + "project-1": { + isRepo: true, + branch: "main", + tracking: "origin/main", + hasRemote: true, + remoteInfo: null, + ahead: 0, + behind: 0, + staged: [], + unstaged: [], + totalInsertions: 12, + totalDeletions: 3, + } as GitStatusResult, + }, + }); + + render( + composerElement({ + thread: { + ...guiThread, + status: "launching", + sessionRef: undefined, + worktreeBranch: "poracode/feature", + }, + }), + ); + + expect(screen.queryByRole("button", { name: "Review changes" })).toBeNull(); + }); + function composerElement(opts?: { thread?: Thread; agentStatus?: AgentStatus; diff --git a/src/renderer/components/thread/ThreadComposerSection.tsx b/src/renderer/components/thread/ThreadComposerSection.tsx index c5c695a5e..1cb619e86 100644 --- a/src/renderer/components/thread/ThreadComposerSection.tsx +++ b/src/renderer/components/thread/ThreadComposerSection.tsx @@ -162,6 +162,10 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread goalDockState, errorDockStates, } = props; + const awaitingWorktree = useAppStore( + (state) => + state.provisioningWorktreeThreadIds[thread.id] === true && thread.status === "launching", + ); const { t } = useLingui(); const [prompt, setPrompt] = useState(""); const [hasContent, setHasContent] = useState(false); @@ -665,11 +669,13 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread <> {thread.status !== "launching" || !usesTerminalPresentation ? (
- + {awaitingWorktree ? null : ( + + )}
{ runtimeItemIdsByThread: {}, runtimeItemsByIdByThread: {}, runtimeRequestsByThread: {}, + provisioningWorktreeThreadIds: {}, }); }); @@ -1825,6 +1826,56 @@ describe("ThreadView", () => { } }); + it("hides base-checkout thread tools while a new worktree is provisioning", async () => { + const thread: Thread = { + id: "thread-worktree-provisioning", + projectId: "project-1", + title: "Provisioning worktree", + agentKind: "codex", + config: { model: "gpt-5.4" }, + status: "launching", + attention: "none", + canResumeWithConfig: false, + worktreeBranch: "poracode/feature", + archived: false, + done: false, + starred: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const props: Parameters[0] = { + thread, + agentStatus: undefined, + projectLocation: { kind: "windows", path: "C:\\repo" }, + paneCount: 2, + }; + useAppStore.setState({ + provisioningWorktreeThreadIds: { [thread.id]: true }, + }); + const { rerender } = renderThreadView(props); + + expect(screen.queryByRole("button", { name: "Show thread tools" })).toBeNull(); + expect(screen.getByText("Creating worktree…")).toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.getByRole("button", { name: "Show thread tools" })).toBeInTheDocument(); + expect(screen.queryByText("Creating worktree…")).toBeNull(); + }); + it("allows queued follow-ups and stop while a GUI ACP thread is running", async () => { renderThreadView({ thread: { diff --git a/src/renderer/components/thread/ThreadView.tsx b/src/renderer/components/thread/ThreadView.tsx index 0fd6f23e8..d70645768 100644 --- a/src/renderer/components/thread/ThreadView.tsx +++ b/src/renderer/components/thread/ThreadView.tsx @@ -73,6 +73,7 @@ function areThreadViewPropsEqual(prev: ThreadViewProps, next: ThreadViewProps): prev.projectName === next.projectName && prev.pendingLaunchPrompt === next.pendingLaunchPrompt && prev.pendingLaunchSegments === next.pendingLaunchSegments && + prev.pendingLaunchUserMessageItemId === next.pendingLaunchUserMessageItemId && prev.isWsl === next.isWsl && prev.showCloseButton === next.showCloseButton && prev.paneAlign === next.paneAlign && @@ -101,6 +102,7 @@ export type ThreadViewProps = { projectName?: string; pendingLaunchPrompt?: string; pendingLaunchSegments?: PromptSegment[]; + pendingLaunchUserMessageItemId?: string; isWsl?: boolean; showCloseButton?: boolean; paneAlign?: "left" | "center" | "right"; @@ -157,6 +159,7 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) { projectName, pendingLaunchPrompt, pendingLaunchSegments, + pendingLaunchUserMessageItemId, isWsl, showCloseButton, paneAlign = "center", @@ -196,6 +199,10 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) { const usesTerminalPresentation = (thread.presentationMode ?? agentStatus?.capabilities.presentationMode ?? "terminal") === "terminal"; + const awaitingWorktree = useAppStore( + (state) => + state.provisioningWorktreeThreadIds[thread.id] === true && thread.status === "launching", + ); const launchTerminalSize = usesTerminalPresentation ? terminalSize : DEFAULT_HIDDEN_TERMINAL_SIZE; useLayoutEffect(() => { @@ -235,6 +242,9 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) { projectLocation, prompt: pendingLaunchPrompt, ...(pendingLaunchSegments ? { segments: pendingLaunchSegments } : {}), + ...(pendingLaunchUserMessageItemId + ? { userMessageItemId: pendingLaunchUserMessageItemId } + : {}), initialSize: launchTerminalSize, }); })().catch((error) => { @@ -247,6 +257,7 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) { onLaunchFailed, pendingLaunchPrompt, pendingLaunchSegments, + pendingLaunchUserMessageItemId, projectLocation, launchTerminalSize, thread, @@ -379,11 +390,13 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) { ) : null} - + {awaitingWorktree ? null : ( + + )} {showCloseButton ? (