diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index d1cac23759..41cb53c32c 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -135,7 +135,7 @@ import { } from "@/browser/utils/chatEditing"; import { type ThinkingLevel } from "@/common/types/thinking"; -import { normalizeRuntimeEnablement } from "@/common/types/runtime"; +import { normalizeRuntimeEnablement, RUNTIME_MODE } from "@/common/types/runtime"; import { type MuxMessageMetadata, type ReviewNoteDataForDisplay, @@ -936,6 +936,13 @@ const ChatInputInner: React.FC = (props) => { // Disable send while Coder presets are loading (user could bypass preset validation) const coderPresetsLoading = coderState.enabled && !coderState.coderConfig?.existingWorkspace && coderState.loadingPresets; + // #6033: the trunk branch stays empty until the branch list loads, and the backend refuses + // every runtime except local without one, so a creation send must wait for the list. + const creationBranchesLoading = + variant === "creation" && + props.kind !== "scratch" && + !creationState.branchesLoaded && + creationState.selectedRuntime.mode !== RUNTIME_MODE.LOCAL; const isProcessingAttachments = processingAttachmentCount > 0; const hasSendableDraft = hasTypedText || hasImages || hasReviews; // Workspace sends/edits mutate history relative to the visible transcript, so they wait for @@ -958,6 +965,7 @@ const ChatInputInner: React.FC = (props) => { !sendInFlightBlocksInput && !isProcessingAttachments && !coderPresetsLoading && + !creationBranchesLoading && !transcriptBlocksSend && !editPreconditionInvalidated; const runningGoalActive = diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 562bcd529d..1663482ac0 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -355,32 +355,33 @@ export function useCreationWorkspace({ let mounted = true; setBranchesLoaded(false); setRuntimeAvailabilityState({ status: "loading" }); - const doLoad = async () => { + // Both requests run at once and settle independently. Send waits for the branch list (#6033), + // so it must not also wait for the runtime probe (Docker, Podman, devcontainer CLI), which can + // take several seconds after the branches are ready. + const loadBranches = async () => { try { - // Use allSettled so failures are independent - branches can load even if availability fails - const [branchResult, availabilityResult] = await Promise.allSettled([ - api.projects.listBranches({ projectPath }), - api.projects.runtimeAvailability({ projectPath }), - ]); + const result = await api.projects.listBranches({ projectPath }); if (!mounted) return; - if (branchResult.status === "fulfilled") { - setBranches(branchResult.value.branches); - setRecommendedTrunk(branchResult.value.recommendedTrunk); - } else { - console.error("Failed to load branches:", branchResult.reason); - } - if (availabilityResult.status === "fulfilled") { - setRuntimeAvailabilityState({ status: "loaded", data: availabilityResult.value }); - } else { - setRuntimeAvailabilityState({ status: "failed" }); - } + setBranches(result.branches); + setRecommendedTrunk(result.recommendedTrunk); + } catch (error) { + console.error("Failed to load branches:", error); } finally { if (mounted) { setBranchesLoaded(true); } } }; - void doLoad(); + const loadRuntimeAvailability = async () => { + try { + const data = await api.projects.runtimeAvailability({ projectPath }); + if (mounted) setRuntimeAvailabilityState({ status: "loaded", data }); + } catch { + if (mounted) setRuntimeAvailabilityState({ status: "failed" }); + } + }; + void loadBranches(); + void loadRuntimeAvailability(); return () => { mounted = false; }; diff --git a/tests/ui/agents/creationWaitsForBranches.test.ts b/tests/ui/agents/creationWaitsForBranches.test.ts new file mode 100644 index 0000000000..af038fcee2 --- /dev/null +++ b/tests/ui/agents/creationWaitsForBranches.test.ts @@ -0,0 +1,322 @@ +/** + * #6033: a creation composer must not send before the project's branch list has loaded. Until it + * loads the trunk branch is empty, and the backend refuses every runtime except local with + * "Trunk branch is required for worktree and SSH runtimes". + */ + +import "../dom"; +import { fireEvent, waitFor } from "@testing-library/react"; + +import { shouldRunIntegrationTests } from "../../testUtils"; +import { + cleanupSharedRepo, + createSharedRepo, + getSharedEnv, + getSharedRepoPath, +} from "../../ipc/sendMessageTestHelpers"; + +import { renderApp } from "../renderReviewPanel"; +import { addProjectViaUI, cleanupView, setupTestDom, waitForLatestDraftId } from "../helpers"; +import { ChatHarness } from "../harness"; + +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { getAgentsInitNudgeKey, getDraftScopeId } from "@/common/constants/storage"; +import type { DraftScope } from "@/common/orpc/schemas/drafts"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +const TRUNK_REQUIRED = "Trunk branch is required"; + +function sendButton(container: HTMLElement): HTMLButtonElement { + const textareas = container.querySelectorAll( + 'textarea[aria-label="Message"]' + ); + const section = textareas[textareas.length - 1]?.closest('[data-component="ChatInputSection"]'); + const button = section?.querySelector('button[aria-label="Send message"]'); + if (!button) throw new Error("Send button not found"); + return button; +} + +async function selectRuntime(container: HTMLElement, label: string): Promise { + const trigger = container.querySelector( + '[data-component="RuntimeTypeGroup"] button[aria-label="Workspace type"]' + ); + if (!trigger) throw new Error("Workspace type trigger not found"); + fireEvent.click(trigger); + const option = await waitFor( + () => { + const candidate = Array.from(document.querySelectorAll('[role="option"]')).find( + (element) => element.textContent?.includes(label) + ); + if (!candidate) throw new Error(`Runtime option '${label}' not found`); + return candidate; + }, + { timeout: 5_000 } + ); + fireEvent.click(option); +} + +/** Holds every listBranches call until release() so the creation view stays "branches loading". */ +function holdBranchList(env: ReturnType) { + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const realListBranches = env.services.projectService.listBranches.bind( + env.services.projectService + ); + const spy = jest + .spyOn(env.services.projectService, "listBranches") + .mockImplementation(async (projectPath: string) => { + await released; + return realListBranches(projectPath); + }); + return { spy, release }; +} + +/** + * The app's API client, with projects.runtimeAvailability held until release(). The router calls + * a module function for it, so the renderer's client is the seam (the backend has no service). + */ +function holdRuntimeAvailability(orpc: ReturnType["orpc"]) { + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const answered: Promise[] = []; + const projects = new Proxy(orpc.projects, { + get(target, key, receiver) { + if (key !== "runtimeAvailability") return Reflect.get(target, key, receiver) as unknown; + return (...args: Parameters) => { + const call = released.then(() => target.runtimeAvailability(...args)); + answered.push(call.catch(() => undefined)); + return call; + }; + }, + }); + const client = new Proxy(orpc, { + get(target, key, receiver) { + return key === "projects" ? projects : (Reflect.get(target, key, receiver) as unknown); + }, + }); + /** Resolves once every probe made so far has answered. */ + const allAnswered = () => Promise.all(answered); + return { client, release, allAnswered }; +} + +function runInitButton(container: HTMLElement): Promise { + return waitFor( + () => { + const button = container.querySelector('[data-testid="agents-init-run"]'); + if (!button) throw new Error("Run /init banner not shown"); + return button; + }, + { timeout: 10_000 } + ); +} + +/** + * Opens the project's creation view without waiting for its branch list: the shared + * openProjectCreationView helper waits for the branch selector, which these tests hold back. + */ +async function openCreationViewWhileBranchesLoad( + view: ReturnType, + projectPath: string +): Promise { + await view.waitForReady(); + const projectRow = await waitFor( + () => { + const row = view.container.querySelector( + `[data-project-path="${projectPath}"][aria-controls]` + ); + if (!row) throw new Error("Project not found in sidebar"); + return row; + }, + { timeout: 10_000 } + ); + fireEvent.click(projectRow); + await waitFor( + () => { + if (!view.container.querySelector('textarea[aria-label="Message"]')) { + throw new Error("Project creation page not rendered"); + } + }, + { timeout: 10_000 } + ); +} + +async function openCreationViewWithHeldBranches(options?: { + showAgentsInitBanner?: boolean; + apiClient?: ReturnType["orpc"]; +}) { + const env = getSharedEnv(); + const cleanupDom = setupTestDom(); + const view = renderApp({ apiClient: options?.apiClient ?? env.orpc }); + const projectPath = await addProjectViaUI(view, getSharedRepoPath()); + if (options?.showAgentsInitBanner) updatePersistedState(getAgentsInitNudgeKey(projectPath), true); + // Held before the creation view mounts: its first listBranches call is the one that matters. + const held = holdBranchList(env); + await openCreationViewWhileBranchesLoad(view, projectPath); + await waitFor(() => expect(held.spy).toHaveBeenCalled(), { timeout: 10_000 }); + const draftId = await waitForLatestDraftId(projectPath); + const scope: DraftScope = { kind: "creation", projectPath, draftId }; + const chat = new ChatHarness(view.container, getDraftScopeId(projectPath, draftId), scope); + const create = jest.spyOn(env.services.workspaceService, "create"); + return { env, view, cleanupDom, chat, held, create }; +} + +describeIntegration("creation composer while the branch list loads (#6033)", () => { + beforeAll(async () => { + await createSharedRepo(); + }); + + afterAll(async () => { + await cleanupSharedRepo(); + }); + + test("a worktree workspace cannot be sent until the branches load, then it uses the loaded trunk", async () => { + const { view, cleanupDom, chat, held, create } = await openCreationViewWithHeldBranches(); + try { + await chat.typeWithoutSending("hello before branches load"); + + // Send is refused while the branch list is pending: the button is disabled and Enter + // creates nothing, so the backend never sees an empty trunk. + await waitFor(() => expect(sendButton(view.container).disabled).toBe(true)); + const textarea = view.container.querySelector( + 'textarea[aria-label="Message"]' + )!; + fireEvent.keyDown(textarea, { key: "Enter" }); + fireEvent.click(sendButton(view.container)); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(create).not.toHaveBeenCalled(); + expect(view.container.textContent ?? "").not.toContain(TRUNK_REQUIRED); + + held.release(); + await waitFor(() => expect(sendButton(view.container).disabled).toBe(false), { + timeout: 10_000, + }); + fireEvent.click(sendButton(view.container)); + await waitFor(() => expect(create).toHaveBeenCalled(), { timeout: 30_000 }); + const trunkBranch = create.mock.calls[0]?.[2]; + expect(typeof trunkBranch === "string" && trunkBranch.length > 0).toBe(true); + expect(view.container.textContent ?? "").not.toContain(TRUNK_REQUIRED); + } finally { + held.release(); + held.spy.mockRestore(); + create.mockRestore(); + await cleanupView(view, cleanupDom); + } + }, 90_000); + + // The ProjectPage "Run /init" banner sends through the composer's send() once. While the branch + // list loads that send is refused like any other: /init stays in the composer and is sent only + // when the user presses Send. Nothing sends later on its own (tracked as a follow-up). + test("Run /init clicked while the branches load sends nothing until the user presses Send", async () => { + const availability = holdRuntimeAvailability(getSharedEnv().orpc); + const { view, cleanupDom, chat, held, create } = await openCreationViewWithHeldBranches({ + showAgentsInitBanner: true, + apiClient: availability.client, + }); + try { + fireEvent.click(await runInitButton(view.container)); + await chat.expectInputValue("/init"); + expect(create).not.toHaveBeenCalled(); + + // Neither request completing sends on its own: branches first, then the runtime probe. + held.release(); + await waitFor(() => expect(sendButton(view.container).disabled).toBe(false), { + timeout: 10_000, + }); + availability.release(); + await availability.allAnswered(); + // A negative check needs a settle window: give React a few frames to commit the probe result. + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(create).not.toHaveBeenCalled(); + await chat.expectInputValue("/init"); + + // An explicit Send submits /init once. + fireEvent.click(sendButton(view.container)); + await waitFor(() => expect(create).toHaveBeenCalledTimes(1), { timeout: 30_000 }); + const trunkBranch = create.mock.calls[0]?.[2]; + expect(typeof trunkBranch === "string" && trunkBranch.length > 0).toBe(true); + } finally { + availability.release(); + held.release(); + held.spy.mockRestore(); + create.mockRestore(); + await cleanupView(view, cleanupDom); + } + }, 90_000); + + test("Run /init clicked after the branches load sends /init at once", async () => { + const { view, cleanupDom, held, create } = await openCreationViewWithHeldBranches({ + showAgentsInitBanner: true, + }); + try { + held.release(); + await waitFor( + () => { + const trigger = view.container.querySelector('[aria-label="Select source branch"]'); + if (!trigger?.textContent || trigger.textContent === "Select source branch") { + throw new Error("source branch not selected yet"); + } + }, + { timeout: 10_000 } + ); + fireEvent.click(await runInitButton(view.container)); + await waitFor(() => expect(create).toHaveBeenCalledTimes(1), { timeout: 30_000 }); + } finally { + held.release(); + held.spy.mockRestore(); + create.mockRestore(); + await cleanupView(view, cleanupDom); + } + }, 90_000); + + // Runtime availability probes Docker, Podman and the devcontainer CLI with multi-second + // timeouts. Send waits for the branch list only, not for that probe. + test("a worktree workspace can be sent once the branches load, while the runtime probe is still pending", async () => { + const availability = holdRuntimeAvailability(getSharedEnv().orpc); + const { view, cleanupDom, chat, held, create } = await openCreationViewWithHeldBranches({ + apiClient: availability.client, + }); + try { + await chat.typeWithoutSending("hello before the runtime probe finishes"); + await waitFor(() => expect(sendButton(view.container).disabled).toBe(true)); + + held.release(); + await waitFor(() => expect(sendButton(view.container).disabled).toBe(false), { + timeout: 10_000, + }); + fireEvent.click(sendButton(view.container)); + await waitFor(() => expect(create).toHaveBeenCalled(), { timeout: 30_000 }); + const trunkBranch = create.mock.calls[0]?.[2]; + expect(typeof trunkBranch === "string" && trunkBranch.length > 0).toBe(true); + } finally { + availability.release(); + held.release(); + held.spy.mockRestore(); + create.mockRestore(); + await cleanupView(view, cleanupDom); + } + }, 90_000); + + test("a local workspace can be sent while the branches are still loading", async () => { + const { view, cleanupDom, chat, held, create } = await openCreationViewWithHeldBranches(); + try { + await selectRuntime(view.container, "Local"); + await chat.typeWithoutSending("hello on the local runtime"); + await waitFor(() => expect(sendButton(view.container).disabled).toBe(false), { + timeout: 10_000, + }); + fireEvent.click(sendButton(view.container)); + await waitFor(() => expect(create).toHaveBeenCalled(), { timeout: 30_000 }); + expect(held.spy.mock.results.length).toBeGreaterThan(0); + } finally { + held.release(); + held.spy.mockRestore(); + create.mockRestore(); + await cleanupView(view, cleanupDom); + } + }, 90_000); +});