diff --git a/webviews/src/agent-session/acpmux/ComposerContext.tsx b/webviews/src/agent-session/acpmux/ComposerContext.tsx index 39a5e00e63b7..a6236923bbc8 100644 --- a/webviews/src/agent-session/acpmux/ComposerContext.tsx +++ b/webviews/src/agent-session/acpmux/ComposerContext.tsx @@ -176,7 +176,7 @@ export function ComposerContext({ /// picker offers only these, whatever the machine's kind. offloads?: readonly OffloadKind[]; /// The New Tab page's saved environments, in the environment picker. While one is picked it - /// alone names where the chat starts: the folder and New Worktree controls step aside. + /// alone names where the chat starts: the folder control steps aside. saved?: SavedEnvironmentRows; /// The machine list opened, and every few seconds while it stays open: read the machines and /// their status again. @@ -449,12 +449,6 @@ export function ComposerContext({ if (savedRows?.current !== undefined) savedRows.onClear(); pickComputer(id); }; - // Preview: New Worktree is a control of its own beside the folder, as Codex's Worktree and - // Claude's chips are (hq#2569 #9), for a new chat on this computer in a folder. - const worktree = - environmentsShown && !readOnly && !savedPick && selectedComputer === "local" && currentFolder && onNewWorktree - ? () => onNewWorktree(currentFolder) - : undefined; const folderShown = !savedPick && !(readOnly && !newChats && !currentFolder); return (
@@ -492,9 +486,7 @@ export function ComposerContext({ onBrowse={onBrowseProject} onNoProject={onNoProject} noProject={noProject && !currentFolder} - onNewWorktree={ - !worktree && currentFolder && onNewWorktree ? () => onNewWorktree(currentFolder) : undefined - } + onNewWorktree={currentFolder && onNewWorktree ? () => onNewWorktree(currentFolder) : undefined} localName={localName} peers={peers} {...(folderNote && !currentFolder ? { note: folderNote } : {})} @@ -534,13 +526,6 @@ export function ComposerContext({ }} /> )} - {worktree && ( - - - - )} {environmentsShown ? ( { + await act(async () => { + root!.render( + undefined} + />, + ); + }); + const strip = container.querySelector(".acpmux-composer-context")!; + expect(strip).toBeTruthy(); + const buttons = [...strip.querySelectorAll("button")].map((button) => button.textContent?.trim()); + expect(buttons.some((text) => text?.includes("New Worktree"))).toBe(false); + const folder = strip.querySelector('[aria-label="Folder"]'); + expect(folder?.textContent).toContain("cmux"); + await act(async () => folder!.click()); + const options = [...document.querySelectorAll('[role="option"]')].map((o) => o.textContent?.trim()); + expect(options).toContain("New Worktree"); +}); + test("a fresh New Tab exposes the no-project path in its environment card", async () => { let browsed = 0; await act(async () => { diff --git a/webviews/src/agent-session/acpmux/startFolder.gallery.ts b/webviews/src/agent-session/acpmux/startFolder.gallery.ts index 8f97b60731a7..9fa779acfbbc 100644 --- a/webviews/src/agent-session/acpmux/startFolder.gallery.ts +++ b/webviews/src/agent-session/acpmux/startFolder.gallery.ts @@ -10,7 +10,7 @@ const AGENT_HOME = "/Users/you/Library/Application Support/cmux/agent-home/6b16a const WORKTREE = "/Users/you/.cmux/worktrees/cmux/fix-sidebar"; -/// A new chat in a project folder: New Worktree is a control beside the folder (cx-lyqio.2, hq#2569 #9). +/// A new chat in a project folder: New Worktree is an action in its folder menu (cx-lyqio.2). // A new chat (no session yet: the summary's empty id matches the snapshot's), so it offers New Worktree. const inProject = noChat([session({ sessionId: "cmux", title: "Fix the sidebar", cwd: "/Users/you/src/cmux" })], { sessionId: "", @@ -18,7 +18,9 @@ const inProject = noChat([session({ sessionId: "cmux", title: "Fix the sidebar", }); const newWorktree: Play = async (ctx) => { - await ctx.click({ role: "button", name: "New Worktree" }); + await ctx.click({ selector: '[aria-label="Folder"]' }); + await ctx.waitFor(() => ctx.document.querySelector(".acpmux-location-menu")); + await ctx.click({ role: "option", name: "New Worktree" }); }; // The home folder's row in the folder menu. Rows show the repository or folder name since #22724, @@ -81,7 +83,7 @@ export default agentPaneEntry({ }, }, "new-worktree": { - note: "Play: New Worktree beside a new chat's folder; the host makes the worktree on its own branch and the chat starts there (the folder chip names it).", + note: "Play: New Worktree from a new chat's folder menu; the host makes the worktree on its own branch and the chat starts there (the folder chip names it).", ready: { newSession: true }, native: { "git.worktree.create": { diff --git a/webviews/test/environment-picker.test.tsx b/webviews/test/environment-picker.test.tsx index d235cb6e3211..35cde57c6e85 100644 --- a/webviews/test/environment-picker.test.tsx +++ b/webviews/test/environment-picker.test.tsx @@ -696,7 +696,8 @@ test("⌥⌘1 on the environment already chosen stays the app's", async () => { expect(calls).toEqual([]); }); -// hq#2569 #9: a new chat chooses a worktree beside its folder, as Codex's Worktree and Claude's chips do. +// A new chat makes a worktree from its folder menu. The bar above the composer holds the folder and +// the machine only (Leo 2026-10-31, on a New Worktree control there: "who the f*** put worktree on there"). const inFolder = { summary: { sessionId: "", @@ -710,13 +711,25 @@ const worktreeButton = () => [...host.querySelectorAll(".acpmux-location-button")].find( (button) => button.textContent?.trim() === t("worktree.new"), ); +// New Worktree from the folder menu. +const newWorktreeFromMenu = async () => { + const folder = host.querySelector( + `.acpmux-composer-context button[aria-label="${t("composer.folder")}"]`, + ); + expect(folder, "no folder control").toBeTruthy(); + await act(async () => folder!.click()); + const action = [...doc().querySelectorAll(".acpmux-location-action")].find( + (item) => item.textContent?.trim() === t("worktree.new"), + ); + expect(action, "no New Worktree in the folder menu").toBeTruthy(); + await act(async () => action!.click()); +}; -test("previews on: New Worktree is a control beside the folder, not inside its menu", async () => { +test("previews on: New Worktree is in the folder menu, not a control beside the folder", async () => { const worktrees: string[] = []; await mount(true, { ...inFolder, onNewWorktree: (cwd: string) => worktrees.push(cwd) }); - const button = worktreeButton(); - expect(button, "no New Worktree beside the folder").toBeTruthy(); - await act(async () => button!.click()); + expect(worktreeButton(), "a New Worktree control beside the folder").toBeUndefined(); + await newWorktreeFromMenu(); expect(worktrees).toEqual(["/Users/you/src/cmux"]); }); @@ -771,7 +784,7 @@ test("New Tab, previews on: New Worktree starts the chat in the worktree the hos return "/Users/you/wt/app-blue-fox"; }); expect(folderName()).toBe("app"); - await act(async () => worktreeButton()!.click()); + await newWorktreeFromMenu(); expect(asked).toEqual(["/Users/you/src/app"]); expect(folderName()).toContain("fox"); }); @@ -780,7 +793,7 @@ test("New Tab, previews on: the host's refusal shows above the card, and the fol await mountNewTab(async () => { throw new Error("not a git repository"); }); - await act(async () => worktreeButton()!.click()); + await newWorktreeFromMenu(); expect(host.querySelector('[role="alert"]')?.textContent).toContain("not a git repository"); expect(folderName()).toBe("app"); }); @@ -788,8 +801,8 @@ test("New Tab, previews on: the host's refusal shows above the card, and the fol test("New Tab, previews on: a slower worktree reply doesn't replace a newer choice", async () => { const replies: ((folder: string) => void)[] = []; await mountNewTab(() => new Promise((resolve) => replies.push(resolve))); - await act(async () => worktreeButton()!.click()); - await act(async () => worktreeButton()!.click()); + await newWorktreeFromMenu(); + await newWorktreeFromMenu(); await act(async () => replies[1]!("/Users/you/wt/app-blue-fox")); await act(async () => replies[0]!("/Users/you/wt/app-red-owl")); expect(folderName()).toContain("fox"); @@ -803,7 +816,7 @@ test("New Tab, previews on: a worktree reply after a machine pick doesn't take t peerConnected: { devbox: true }, }); await mountNewTab(() => new Promise((resolve) => replies.push(resolve)), snapshot); - await act(async () => worktreeButton()!.click()); + await newWorktreeFromMenu(); await openPicker(); const devbox = machineRows().find((row) => row.textContent?.includes("devbox"))!; await act(async () => devbox.click()); diff --git a/webviews/test/new-tab-warm-first-frame.test.ts b/webviews/test/new-tab-warm-first-frame.test.ts index 204e7ac8a319..66dc7fe84b7a 100644 --- a/webviews/test/new-tab-warm-first-frame.test.ts +++ b/webviews/test/new-tab-warm-first-frame.test.ts @@ -296,8 +296,8 @@ await requireBrowserLane("new-tab-warm-first-frame.test.ts", async () => { expect(painted).toHaveLength(1); expect(painted[0]).toContain("Fix the atlas build"); expect(painted[0]).toContain("Older device chat"); - // The environment picker puts New Worktree beside the folder (not behind Labs since #29059). - expect(painted[0]).toStartWith("atlas New Worktree cosmic-gray ::"); + // The bar names the folder and the machine; New Worktree is in the folder menu only. + expect(painted[0]).toStartWith("atlas cosmic-gray ::"); }, ); } @@ -331,7 +331,7 @@ await requireBrowserLane("new-tab-warm-first-frame.test.ts", async () => { expect(painted).toHaveLength(1); expect(painted[0]).toContain("Fix the atlas build"); // The adopting tab's project, not the spare's "Choose folder". - expect(painted[0]).toStartWith("atlas New Worktree cosmic-gray ::"); + expect(painted[0]).toStartWith("atlas cosmic-gray ::"); }, );