Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 2 additions & 17 deletions webviews/src/agent-session/acpmux/ComposerContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
<div className="acpmux-composer-strip">
Expand Down Expand Up @@ -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 } : {})}
Expand Down Expand Up @@ -534,13 +526,6 @@ export function ComposerContext({
}}
/>
)}
{worktree && (
<span className="acpmux-location-picker">
<button type="button" className="acpmux-location-button ui-ghost ui-ghost-sm" onClick={worktree}>
<LocationFace icon={<BranchIcon />} value={t("worktree.new")} chevron={false} />
</button>
</span>
)}
{environmentsShown ? (
<EnvironmentPicker
label={t(CONTEXT_LABELS.environment)}
Expand Down
26 changes: 26 additions & 0 deletions webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,32 @@ test("cancelling a saved environment browse keeps the New Tab project", async ()
expect(container.querySelector(".nt-location")?.textContent).toBe(before);
});

// Leo, 2026-10-31: "who the f*** put worktree on there". The bar above the composer holds
// the folder and the machine only; New Worktree is an action in the folder menu.
test("the location bar has no New Worktree control; the folder menu still offers it", async () => {
await act(async () => {
root!.render(
<NewTabScreen
{...firstRunProps}
cwd="/Users/you/src/cmux"
startCwd="/Users/you/src/cmux"
projects={[{ cwd: "/Users/you/src/cmux", label: "cmux" }]}
environments
onNewWorktree={async () => 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<HTMLButtonElement>('[aria-label="Folder"]');
expect(folder?.textContent).toContain("cmux");
await act(async () => folder!.click());
const options = [...document.querySelectorAll<HTMLElement>('[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 () => {
Expand Down
8 changes: 5 additions & 3 deletions webviews/src/agent-session/acpmux/startFolder.gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@ 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: "",
summary: { sessionId: "", cwd: "/Users/you/src/cmux", harness: "claude", model: "claude-opus-5-5", effort: "high" },
});

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,
Expand Down Expand Up @@ -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": {
Expand Down
33 changes: 23 additions & 10 deletions webviews/test/environment-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand All @@ -710,13 +711,25 @@ const worktreeButton = () =>
[...host.querySelectorAll<HTMLButtonElement>(".acpmux-location-button")].find(
(button) => button.textContent?.trim() === t("worktree.new"),
);
// New Worktree from the folder menu.
const newWorktreeFromMenu = async () => {
const folder = host.querySelector<HTMLButtonElement>(
`.acpmux-composer-context button[aria-label="${t("composer.folder")}"]`,
);
expect(folder, "no folder control").toBeTruthy();
await act(async () => folder!.click());
const action = [...doc().querySelectorAll<HTMLElement>(".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"]);
});

Expand Down Expand Up @@ -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");
});
Expand All @@ -780,16 +793,16 @@ 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");
});

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<string | undefined>((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");
Expand All @@ -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<string | undefined>((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());
Expand Down
6 changes: 3 additions & 3 deletions webviews/test/new-tab-warm-first-frame.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ::");
},
);
}
Expand Down Expand Up @@ -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 ::");
},
);

Expand Down
Loading