From 719a729af794280e3a507723cc27b68d4a75d205 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 10 Oct 2026 09:33:50 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20a=20new=20workspace?= =?UTF-8?q?=20waits=20for=20its=20branch=20list=20before=20it=20can=20be?= =?UTF-8?q?=20sent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the creation view's branch list loads, the trunk branch is empty, and the backend refuses every runtime except local with "Trunk branch is required for worktree and SSH runtimes". Send (button, Enter and slash commands) now stays disabled until the list loads, except for the local runtime and scratch chats. Fixes #6033 --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- src/browser/features/ChatInput/index.tsx | 10 +- .../agents/creationWaitsForBranches.test.ts | 159 ++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 tests/ui/agents/creationWaitsForBranches.test.ts diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index d1cac23759f..41cb53c32c3 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/tests/ui/agents/creationWaitsForBranches.test.ts b/tests/ui/agents/creationWaitsForBranches.test.ts new file mode 100644 index 00000000000..d03076ba977 --- /dev/null +++ b/tests/ui/agents/creationWaitsForBranches.test.ts @@ -0,0 +1,159 @@ +/** + * #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, + openProjectCreationView, + setupTestDom, + waitForLatestDraftId, +} from "../helpers"; +import { ChatHarness } from "../harness"; + +import { 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 }; +} + +async function openCreationViewWithHeldBranches() { + const env = getSharedEnv(); + const cleanupDom = setupTestDom(); + const view = renderApp({ apiClient: env.orpc }); + const projectPath = await addProjectViaUI(view, getSharedRepoPath()); + // Held before the creation view mounts: its first listBranches call is the one that matters. + const held = holdBranchList(env); + await openProjectCreationView(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); + + 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); +}); From f4e9028b57291fd0d44e4f75feef4b2fa43e5e38 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 10 Oct 2026 10:01:25 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20Run=20/init=20clicked?= =?UTF-8?q?=20while=20the=20branch=20list=20loads=20sends=20once=20it=20lo?= =?UTF-8?q?ads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectPage's "Run /init" banner calls the composer's imperative send() once. With Send gated while the branch list loads (#6033), that call was refused and nothing retried it, so the click did nothing. The composer now keeps an imperative send made while the branches load and sends it once they have loaded. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- src/browser/features/ChatInput/index.tsx | 18 +++++++++ .../agents/creationWaitsForBranches.test.ts | 39 ++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 41cb53c32c3..c9fa22db2b3 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -1259,9 +1259,26 @@ const ChatInputInner: React.FC = (props) => { ); const handleSendRef = useRef<() => Promise>(() => Promise.resolve()); + // #6033: callers of the imperative send() (ProjectPage's "Run /init" banner) call it once. While + // the branch list loads, Send is refused, so the call is kept and sent once the list loads. + const creationBranchesLoadingRef = useRef(creationBranchesLoading); + const sendAfterBranchesLoadRef = useRef(false); const send = useCallback(() => { + if (creationBranchesLoadingRef.current) { + sendAfterBranchesLoadRef.current = true; + return Promise.resolve(); + } return handleSendRef.current(); }, []); + useEffect(() => { + if (creationBranchesLoading || !sendAfterBranchesLoadRef.current) return; + sendAfterBranchesLoadRef.current = false; + // Passive effects run after the layout effect below has pointed handleSendRef at this + // commit's handleSend, whose canSend already sees the loaded branches. + handleSendRef.current().catch((error: unknown) => { + console.error("Deferred creation send failed:", error); + }); + }, [creationBranchesLoading]); const onReady = props.onReady; @@ -3014,6 +3031,7 @@ const ChatInputInner: React.FC = (props) => { // render-time write, which React Compiler rejects) updates it in the commit's own task. useLayoutEffect(() => { handleSendRef.current = handleSend; + creationBranchesLoadingRef.current = creationBranchesLoading; }); // Handler for Escape in vim normal mode - cancels edit if editing diff --git a/tests/ui/agents/creationWaitsForBranches.test.ts b/tests/ui/agents/creationWaitsForBranches.test.ts index d03076ba977..5b2b6db4cff 100644 --- a/tests/ui/agents/creationWaitsForBranches.test.ts +++ b/tests/ui/agents/creationWaitsForBranches.test.ts @@ -25,7 +25,8 @@ import { } from "../helpers"; import { ChatHarness } from "../harness"; -import { getDraftScopeId } from "@/common/constants/storage"; +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; @@ -79,11 +80,12 @@ function holdBranchList(env: ReturnType) { return { spy, release }; } -async function openCreationViewWithHeldBranches() { +async function openCreationViewWithHeldBranches(options?: { showAgentsInitBanner?: boolean }) { const env = getSharedEnv(); const cleanupDom = setupTestDom(); const view = renderApp({ 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 openProjectCreationView(view, projectPath); @@ -138,6 +140,39 @@ describeIntegration("creation composer while the branch list loads (#6033)", () } }, 90_000); + // The ProjectPage "Run /init" banner sends through the composer's imperative send(), once. + test("Run /init clicked while the branches load sends /init once they load", async () => { + const { view, cleanupDom, held, create } = await openCreationViewWithHeldBranches({ + showAgentsInitBanner: true, + }); + try { + const runInit = await waitFor( + () => { + const button = view.container.querySelector( + '[data-testid="agents-init-run"]' + ); + if (!button) throw new Error("Run /init banner not shown"); + return button; + }, + { timeout: 10_000 } + ); + fireEvent.click(runInit); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(create).not.toHaveBeenCalled(); + expect(view.container.textContent ?? "").not.toContain(TRUNK_REQUIRED); + + held.release(); + 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 { + 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 { From 4c07d6879df8e67d7cc712aee3390d1273764a04 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 10 Oct 2026 10:25:25 +0000 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20an=20edit=20after=20R?= =?UTF-8?q?un=20/init=20withdraws=20the=20deferred=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferred send kept only the fact that send() was called, so a user who replaced /init with another prompt while the branch list loaded had that prompt sent without pressing Send. The deferred send now holds the draft text at the time of the call, and any edit withdraws it. The edited text stays in the composer for the user to send. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- src/browser/features/ChatInput/index.tsx | 21 +++++++++--- .../agents/creationWaitsForBranches.test.ts | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index c9fa22db2b3..befa7425721 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -1261,24 +1261,34 @@ const ChatInputInner: React.FC = (props) => { const handleSendRef = useRef<() => Promise>(() => Promise.resolve()); // #6033: callers of the imperative send() (ProjectPage's "Run /init" banner) call it once. While // the branch list loads, Send is refused, so the call is kept and sent once the list loads. + // It holds the draft text at the time of the call (null: nothing deferred). Any edit after the + // call withdraws it: only the text the caller asked to send is sent without a Send press. const creationBranchesLoadingRef = useRef(creationBranchesLoading); - const sendAfterBranchesLoadRef = useRef(false); + const draftScopeRef = useRef(draftScope); + const deferredSendTextRef = useRef(null); const send = useCallback(() => { if (creationBranchesLoadingRef.current) { - sendAfterBranchesLoadRef.current = true; + // Read from the store: it applies restoreText() synchronously, before any re-render. + deferredSendTextRef.current = getDraftStore().getText(draftScopeRef.current); return Promise.resolve(); } return handleSendRef.current(); }, []); useEffect(() => { - if (creationBranchesLoading || !sendAfterBranchesLoadRef.current) return; - sendAfterBranchesLoadRef.current = false; + const deferredText = deferredSendTextRef.current; + if (deferredText === null) return; + if (input !== deferredText) { + deferredSendTextRef.current = null; + return; + } + if (creationBranchesLoading) return; + deferredSendTextRef.current = null; // Passive effects run after the layout effect below has pointed handleSendRef at this // commit's handleSend, whose canSend already sees the loaded branches. handleSendRef.current().catch((error: unknown) => { console.error("Deferred creation send failed:", error); }); - }, [creationBranchesLoading]); + }, [creationBranchesLoading, input]); const onReady = props.onReady; @@ -3032,6 +3042,7 @@ const ChatInputInner: React.FC = (props) => { useLayoutEffect(() => { handleSendRef.current = handleSend; creationBranchesLoadingRef.current = creationBranchesLoading; + draftScopeRef.current = draftScope; }); // Handler for Escape in vim normal mode - cancels edit if editing diff --git a/tests/ui/agents/creationWaitsForBranches.test.ts b/tests/ui/agents/creationWaitsForBranches.test.ts index 5b2b6db4cff..99a7cf73af1 100644 --- a/tests/ui/agents/creationWaitsForBranches.test.ts +++ b/tests/ui/agents/creationWaitsForBranches.test.ts @@ -173,6 +173,40 @@ describeIntegration("creation composer while the branch list loads (#6033)", () } }, 90_000); + test("Run /init then an edit while the branches load sends nothing until the user presses Send", async () => { + const { view, cleanupDom, chat, held, create } = await openCreationViewWithHeldBranches({ + showAgentsInitBanner: true, + }); + try { + const runInit = await waitFor( + () => { + const button = view.container.querySelector( + '[data-testid="agents-init-run"]' + ); + if (!button) throw new Error("Run /init banner not shown"); + return button; + }, + { timeout: 10_000 } + ); + fireEvent.click(runInit); + await chat.expectInputValue("/init"); + await chat.typeWithoutSending("a prompt the user has not sent yet"); + + held.release(); + await waitFor(() => expect(sendButton(view.container).disabled).toBe(false), { + timeout: 10_000, + }); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + expect(create).not.toHaveBeenCalled(); + await chat.expectInputValue("a prompt the user has not sent yet"); + } finally { + 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 { From 0fa110542917da4285652ac2ab7eadb283277925 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 10 Oct 2026 10:41:59 +0000 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20the=20branch=20list?= =?UTF-8?q?=20readies=20Send=20without=20waiting=20for=20the=20runtime=20p?= =?UTF-8?q?robe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The creation view loaded branches and runtime availability with one Promise.allSettled and marked the branches loaded only after both settled. With Send now gated on the branch list (#6033), the runtime probe (Docker, Podman, devcontainer CLI, multi-second timeouts) held worktree and SSH sends even after the branches were ready. Each request now settles on its own. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- .../ChatInput/useCreationWorkspace.ts | 37 +++++------ .../agents/creationWaitsForBranches.test.ts | 61 ++++++++++++++++++- 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 562bcd529d3..1663482ac0a 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 index 99a7cf73af1..2c48fd95d46 100644 --- a/tests/ui/agents/creationWaitsForBranches.test.ts +++ b/tests/ui/agents/creationWaitsForBranches.test.ts @@ -80,10 +80,39 @@ function holdBranchList(env: ReturnType) { return { spy, release }; } -async function openCreationViewWithHeldBranches(options?: { showAgentsInitBanner?: boolean }) { +/** + * 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 projects = new Proxy(orpc.projects, { + get(target, key, receiver) { + if (key !== "runtimeAvailability") return Reflect.get(target, key, receiver) as unknown; + return async (...args: Parameters) => { + await released; + return target.runtimeAvailability(...args); + }; + }, + }); + const client = new Proxy(orpc, { + get(target, key, receiver) { + return key === "projects" ? projects : (Reflect.get(target, key, receiver) as unknown); + }, + }); + return { client, release }; +} + +async function openCreationViewWithHeldBranches(options?: { + showAgentsInitBanner?: boolean; + apiClient?: ReturnType["orpc"]; +}) { const env = getSharedEnv(); const cleanupDom = setupTestDom(); - const view = renderApp({ apiClient: env.orpc }); + 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. @@ -207,6 +236,34 @@ describeIntegration("creation composer while the branch list loads (#6033)", () } }, 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 { From cafc53d4736986dffd9591ea9db58ae209f28a90 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 10 Oct 2026 11:04:24 +0000 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20remove=20the=20deferr?= =?UTF-8?q?ed=20Run=20/init=20send;=20/init=20waits=20for=20a=20Send=20pre?= =?UTF-8?q?ss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review rounds found new holes in the deferred send (an edited draft sent without a Send press, the dev container runtime probe dropping the intent, attachments added after the click uploaded on their own). Scope reduction: the imperative send() is refused while the branch list loads like any other send. "Run /init" leaves /init in the composer, and the user presses Send once the branches load. The Send gate and the independent branch loader stay. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- src/browser/features/ChatInput/index.tsx | 29 ------ .../agents/creationWaitsForBranches.test.ts | 93 +++++++++++-------- 2 files changed, 53 insertions(+), 69 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index befa7425721..41cb53c32c3 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -1259,36 +1259,9 @@ const ChatInputInner: React.FC = (props) => { ); const handleSendRef = useRef<() => Promise>(() => Promise.resolve()); - // #6033: callers of the imperative send() (ProjectPage's "Run /init" banner) call it once. While - // the branch list loads, Send is refused, so the call is kept and sent once the list loads. - // It holds the draft text at the time of the call (null: nothing deferred). Any edit after the - // call withdraws it: only the text the caller asked to send is sent without a Send press. - const creationBranchesLoadingRef = useRef(creationBranchesLoading); - const draftScopeRef = useRef(draftScope); - const deferredSendTextRef = useRef(null); const send = useCallback(() => { - if (creationBranchesLoadingRef.current) { - // Read from the store: it applies restoreText() synchronously, before any re-render. - deferredSendTextRef.current = getDraftStore().getText(draftScopeRef.current); - return Promise.resolve(); - } return handleSendRef.current(); }, []); - useEffect(() => { - const deferredText = deferredSendTextRef.current; - if (deferredText === null) return; - if (input !== deferredText) { - deferredSendTextRef.current = null; - return; - } - if (creationBranchesLoading) return; - deferredSendTextRef.current = null; - // Passive effects run after the layout effect below has pointed handleSendRef at this - // commit's handleSend, whose canSend already sees the loaded branches. - handleSendRef.current().catch((error: unknown) => { - console.error("Deferred creation send failed:", error); - }); - }, [creationBranchesLoading, input]); const onReady = props.onReady; @@ -3041,8 +3014,6 @@ const ChatInputInner: React.FC = (props) => { // render-time write, which React Compiler rejects) updates it in the commit's own task. useLayoutEffect(() => { handleSendRef.current = handleSend; - creationBranchesLoadingRef.current = creationBranchesLoading; - draftScopeRef.current = draftScope; }); // Handler for Escape in vim normal mode - cancels edit if editing diff --git a/tests/ui/agents/creationWaitsForBranches.test.ts b/tests/ui/agents/creationWaitsForBranches.test.ts index 2c48fd95d46..7c7e9008b89 100644 --- a/tests/ui/agents/creationWaitsForBranches.test.ts +++ b/tests/ui/agents/creationWaitsForBranches.test.ts @@ -89,12 +89,14 @@ function holdRuntimeAvailability(orpc: ReturnType["orpc"]) 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 async (...args: Parameters) => { - await released; - return target.runtimeAvailability(...args); + return (...args: Parameters) => { + const call = released.then(() => target.runtimeAvailability(...args)); + answered.push(call.catch(() => undefined)); + return call; }; }, }); @@ -103,7 +105,20 @@ function holdRuntimeAvailability(orpc: ReturnType["orpc"]) return key === "projects" ? projects : (Reflect.get(target, key, receiver) as unknown); }, }); - return { client, release }; + /** 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 } + ); } async function openCreationViewWithHeldBranches(options?: { @@ -169,32 +184,39 @@ describeIntegration("creation composer while the branch list loads (#6033)", () } }, 90_000); - // The ProjectPage "Run /init" banner sends through the composer's imperative send(), once. - test("Run /init clicked while the branches load sends /init once they load", async () => { - const { view, cleanupDom, held, create } = await openCreationViewWithHeldBranches({ + // 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 { - const runInit = await waitFor( - () => { - const button = view.container.querySelector( - '[data-testid="agents-init-run"]' - ); - if (!button) throw new Error("Run /init banner not shown"); - return button; - }, - { timeout: 10_000 } - ); - fireEvent.click(runInit); - await new Promise((resolve) => setTimeout(resolve, 500)); + fireEvent.click(await runInitButton(view.container)); + await chat.expectInputValue("/init"); expect(create).not.toHaveBeenCalled(); - expect(view.container.textContent ?? "").not.toContain(TRUNK_REQUIRED); + // Neither request completing sends on its own: branches first, then the runtime probe. held.release(); - await waitFor(() => expect(create).toHaveBeenCalled(), { timeout: 30_000 }); + 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(); @@ -202,32 +224,23 @@ describeIntegration("creation composer while the branch list loads (#6033)", () } }, 90_000); - test("Run /init then an edit while the branches load sends nothing until the user presses Send", async () => { - const { view, cleanupDom, chat, held, create } = await openCreationViewWithHeldBranches({ + test("Run /init clicked after the branches load sends /init at once", async () => { + const { view, cleanupDom, held, create } = await openCreationViewWithHeldBranches({ showAgentsInitBanner: true, }); try { - const runInit = await waitFor( + held.release(); + await waitFor( () => { - const button = view.container.querySelector( - '[data-testid="agents-init-run"]' - ); - if (!button) throw new Error("Run /init banner not shown"); - return button; + 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(runInit); - await chat.expectInputValue("/init"); - await chat.typeWithoutSending("a prompt the user has not sent yet"); - - held.release(); - await waitFor(() => expect(sendButton(view.container).disabled).toBe(false), { - timeout: 10_000, - }); - await new Promise((resolve) => setTimeout(resolve, 1_000)); - expect(create).not.toHaveBeenCalled(); - await chat.expectInputValue("a prompt the user has not sent yet"); + fireEvent.click(await runInitButton(view.container)); + await waitFor(() => expect(create).toHaveBeenCalledTimes(1), { timeout: 30_000 }); } finally { held.release(); held.spy.mockRestore(); From 0f17e12b15c0412ff5ca2c780814fdab5c73dac2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 10 Oct 2026 11:44:02 +0000 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20tests:=20open=20the=20creati?= =?UTF-8?q?on=20view=20without=20waiting=20for=20the=20held=20branch=20lis?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #6032, openProjectCreationView waits for the branch selector. The #6033 tests hold listBranches before they open the view, so on main the helper timed out ("Source branches not loaded"). The tests now open the view with their own click-and-wait for the composer. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- .../agents/creationWaitsForBranches.test.ts | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/tests/ui/agents/creationWaitsForBranches.test.ts b/tests/ui/agents/creationWaitsForBranches.test.ts index 7c7e9008b89..af038fcee24 100644 --- a/tests/ui/agents/creationWaitsForBranches.test.ts +++ b/tests/ui/agents/creationWaitsForBranches.test.ts @@ -16,13 +16,7 @@ import { } from "../../ipc/sendMessageTestHelpers"; import { renderApp } from "../renderReviewPanel"; -import { - addProjectViaUI, - cleanupView, - openProjectCreationView, - setupTestDom, - waitForLatestDraftId, -} from "../helpers"; +import { addProjectViaUI, cleanupView, setupTestDom, waitForLatestDraftId } from "../helpers"; import { ChatHarness } from "../harness"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; @@ -121,6 +115,36 @@ function runInitButton(container: HTMLElement): Promise { ); } +/** + * 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"]; @@ -132,7 +156,7 @@ async function openCreationViewWithHeldBranches(options?: { 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 openProjectCreationView(view, projectPath); + 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 };