Skip to content
Merged
64 changes: 64 additions & 0 deletions design/composer-menus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Composer menus: the project and machine pickers

The composer's location controls (folder or project, machine or environment, branch) and the
menus they open: `webviews/src/agent-session/acpmux/ComposerContext.tsx`, `FolderPopover.tsx`,
`EnvironmentPicker.tsx`, `locationPopover.ts`, `composerLocation.css`, and the New Tab page's copy
in `newtab/NewTabScreen.tsx`. One composer, layouts by intent: the chat pane and New Tab draw the
same controls.

## Placement (Leo 2026-10-29)

> "project and cloudness at the top. make them open away from teh chat, which is obviously up if
> it's up, down if it's down. and stop making them take up so much space."

- **Where they sit:** on the New Tab page, the folder and machine controls head the composer,
over the box, in every New Tab look; the quiet row under the box keeps only the mode, the model
and the context ring. Leo, 2026-10-29: "we'll be wanting to put the project/machine (cloud)
picker thing above the composer again (the is obv relevant for the new tab page and not really
the in-agent-chat page)". The composer inside an agent chat keeps its own row as it is (a new
chat's strip over its card, gone after the first send); this rule does not move it.
- **Which way a menu opens** (wherever the pickers appear): away from the conversation, read from where the composer sits in
the window when the menu opens (`sideAwayFromChat`), never from which page it is. A composer
whose middle is in the lower half of the window has the chat above it: its menus open down. One
in the upper half (New Tab, with the chats under it) opens them up.
- **When that side is short of room:** the menu keeps its side and slides along it to stay in the
window, over its own composer (`flip: "shift"`, `ui/openSide.ts`), so it never opens onto the
chat. It may cover its own control while open. The side is chosen once, on open, and holds
while the list filters or refreshes.
- **Outside a composer** (a gallery stage, any host without one) the menus keep the shared rule:
below when the full menu fits, else the side with more room.
- Menus open on the click, in one frame, with no fade-in (rulebook); closing may fade.

## Size

- Wherever the pickers appear: content-sized pills side by side, folder first, then machine: never spread across the row.
- One strip height for both composers: 24 px for the strip, 6 px to the box.
- 4 px between the controls (`--acpmux-location-gap`); the ghost buttons' own insets do the rest.
- The strip's first icon stands on the quiet row's icon column (New Tab) or the prompt's first
column (chat): one line for icons, one centre line for the pills.
- A long project name cuts in the middle; the machine never shrinks, it drops its name in a narrow
strip and keeps its icon.

## Cloud machines in the machine menu

- The account's Cloud machines acpmux does not list show with their stage: Paused · Resume,
Open, Starting…, and Couldn't start for a machine that failed (never "Not connected").
- A pick on one of them resumes or opens it, and the control names it as starting
("build box · Starting…"). The chat moves there once acpmux lists it ready, so a paused machine
is one pick. Any other pick ends the wait.
- A resume reads Starting… at once, before the app's list comes back.
- New Cloud Machine says what one gives ("Keeps working while your Mac sleeps") in both the plain
Computer menu and the environment picker.

## Gallery

`agent-pane.composer-context-away`: both composer positions with each menu open (the plays fail on
a menu that opens toward the chat), and the strip's surface variants. Picked: plain text over the
box. The bump, the tray and the plus-edge alignment stay Debug choices
(`agentPane.debug.topStripStyle`).

## Never

- A location control in New Tab's quiet row under the box.
- A location menu that opens onto the conversation because the other side had more room.
- Pills stretched to fill the row, or a gap that grows with the pane.
2 changes: 1 addition & 1 deletion webviews/scripts/design-tokens-baseline.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
{"file":"agent-session/acpmux/composerControls.css","kind":"spacing","property":"padding","values":{"0 8px 6px":1,"6px 10px 4px":1,"0 6px 0 8px":1}}
{"file":"agent-session/acpmux/composerLocation.css","kind":"height","property":"height","values":{"46px":1,"calc(46px + var(--acpmux-strip-gap, 4px))":2,"22px":4,"1px":3,"12px":1,"16px":3,"6px":1}}
{"file":"agent-session/acpmux/composerLocation.css","kind":"height","property":"min-height","values":{"52px":1,"16px":1}}
{"file":"agent-session/acpmux/composerLocation.css","kind":"spacing","property":"gap","values":{"var(--acpmux-location-gap, 6px)":2,"1px":1,"6px":1,"3px":1}}
{"file":"agent-session/acpmux/composerLocation.css","kind":"spacing","property":"gap","values":{"1px":1,"6px":1,"3px":1}}
{"file":"agent-session/acpmux/composerLocation.css","kind":"spacing","property":"padding","values":{"0 var(--acpmux-strip-inset, 3px)":1,"0 6px":2,"0 3px":1,"5px 4px":1,"4px 4px 2px":1,"0 5px":1,"0 calc(var(--acpmux-top-strip-edge) - var(--acpmux-tray-inset) - 6px) var(--acpmux-tray-tuck)":1,"0 0 calc(2px + var(--acpmux-tray-tuck)) calc(var(--acpmux-top-strip-edge) - var(--acpmux-tray-inset))":1,"6px 8px":1,"1px 4px":1,"calc(11px * var(--cmux-interface-scale, 1)) max(0px, calc(8px * var(--cmux-interface-scale, 1) - var(--cmux-scroller-size, 8px))) calc(11px * var(--cmux-interface-scale, 1)) calc(7px * var(--cmux-interface-scale, 1))":1}}
{"file":"agent-session/acpmux/composerLocation.css","kind":"spacing","property":"padding-block","values":{"6px":1}}
{"file":"agent-session/acpmux/composerLocation.css","kind":"spacing","property":"padding-bottom","values":{"2px":1}}
Expand Down
90 changes: 77 additions & 13 deletions webviews/src/agent-session/acpmux/ComposerContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Combobox } from "../../ui/Combobox";
import { Menu, MenuButton, MenuPopup, MenuRadioGroup, MenuRadioItem } from "../../ui/Menu";
import { Popover } from "../../ui/Popover";
Expand Down Expand Up @@ -221,6 +221,16 @@ export function ComposerContext({
if (picked.key !== pickKey) setPicked({ key: pickKey });
const selectedComputer = picked.key === pickKey && picked.computer ? picked.computer : initialComputer;
const setSelectedComputer = (computer: string) => setPicked({ key: pickKey, computer });
// A Cloud machine picked while it was paused or not yet linked (pickCloudMachine below), until
// acpmux lists it ready, another machine is picked, or the chat changes. A change drops it for
// good, so a chat that comes back to the same key (an empty New Tab id) never inherits it; a
// machine the account stops listing ends it for good too (it does not come back with the
// machine), and one that failed to start stops it moving.
const [waitingPick, setWaiting] = useState<{ key: string; id: string; name: string } | undefined>(undefined);
const waitMachine = waitingPick?.key === pickKey ? shownMachines.find((m) => m.id === waitingPick.id) : undefined;
if (waitingPick && !waitMachine) setWaiting(undefined);
const waiting = waitMachine ? waitingPick : undefined;
const waitFailed = waitMachine?.stage === "failed";
const folders = useMemo(() => {
const known = availableFolders(summary, sessions, selectedComputer);
if (selectedComputer !== "local" || !projectChoices) return known;
Expand All @@ -243,6 +253,21 @@ export function ComposerContext({
? undefined
: folders[0]?.id;
const currentComputer = computers.find((computer) => computer.id === selectedComputer) ?? computers[0];
// The machine the pick waits for, once acpmux lists it ready: its own row, not the account's.
const arrived =
waiting && !waitFailed
? computers.find(
(computer) =>
!computer.cloudMachine &&
computer.label.toLowerCase() === waiting.name.toLowerCase() &&
environmentReady(computer),
)
: undefined;
const arrive = useRef<(id: string) => void>(() => undefined);
const arrivedId = arrived?.id;
useEffect(() => {
if (arrivedId) arrive.current(arrivedId);
}, [arrivedId]);
if (!currentComputer && !currentFolder && !projectChoices) return <div className="acpmux-composer-strip" />;
const readOnly = started || onProject === undefined;
const sandboxPicked = runMode?.mode === "sandboxed" && !started && !readOnly;
Expand Down Expand Up @@ -280,21 +305,36 @@ export function ComposerContext({
});
}
: undefined;
// An account Cloud machine acpmux does not list runs its machine action: no chat starts on it here.
// An account Cloud machine acpmux does not list runs its machine action (resume or open); the
// chat cannot start there until acpmux links it. The pick waits for that (`waiting`): the
// control names the machine as starting, and the chat moves there once its row is ready, so a
// paused machine is one pick, not a pick, a wait and the same pick again.
const pickCloudMachine = (id: string) => {
const machine = computers.find((computer) => computer.id === id)?.cloudMachine;
if (machine && !readOnly) onCloudMachine(machine);
if (machine && !readOnly) {
onCloudMachine(machine);
setWaiting({ key: pickKey, id: machine.id, name: machine.name });
}
return machine !== undefined;
};
const pickComputer = (id: string) => {
if (pickCloudMachine(id)) return;
if (waiting) setWaiting(undefined);
if (!readOnly && id !== selectedComputer) {
setSelectedComputer(id);
const folder = availableFolders(summary, sessions, id)[0]?.id;
if (folder) onProject?.(folder, id === "local" ? undefined : id);
else onComputer?.(id);
}
};
arrive.current = (id) => {
setWaiting(undefined);
pickComputer(id);
};
// The machine control's name: the machine a Cloud pick waits for, as starting, else the current one.
const waitingName = waiting
? `${waiting.name} · ${t(waitFailed ? "switch.failedShort" : "composer.envStarting")}`
: undefined;
// A started chat's picker lists only where it runs and its move (EnvironmentPicker `offload`).
const offload: OffloadKind | undefined = started && environments && onOffload ? offloads[0] : undefined;
// A first computer is discoverable without Labs, including from a started local chat.
Expand Down Expand Up @@ -404,12 +444,18 @@ export function ComposerContext({
value={
sandboxPicked
? t("composer.sandboxed")
: (savedPick?.name ?? currentComputer?.label ?? t(CONTEXT_LABELS.chooseComputer))
: (waitingName ?? savedPick?.name ?? currentComputer?.label ?? t(CONTEXT_LABELS.chooseComputer))
}
face={
// Sandboxed picked (hq#3367): the control names it where the machine was.
sandboxPicked ? (
<LocationFace icon={<EnvironmentIcon kind="cloud" />} value={t("composer.sandboxed")} chevron />
) : waiting && waitingName ? (
<LocationFace
icon={<EnvironmentIcon kind="cloud" name={waiting.name} />}
value={waitingName}
chevron
/>
) : (
<LocationFace
icon={
Expand Down Expand Up @@ -437,8 +483,12 @@ export function ComposerContext({
label={t(CONTEXT_LABELS.computer)}
menu="Computer"
kind="computer"
icon={<ComputerIcon name={currentComputer?.kind === "local" ? undefined : currentComputer?.label} />}
value={currentComputer?.label ?? t(CONTEXT_LABELS.chooseComputer)}
icon={
<ComputerIcon
name={waiting?.name ?? (currentComputer?.kind === "local" ? undefined : currentComputer?.label)}
/>
}
value={waitingName ?? currentComputer?.label ?? t(CONTEXT_LABELS.chooseComputer)}
options={menuComputers}
selected={selectedComputer}
disabled={computerDisabled}
Expand All @@ -454,13 +504,20 @@ export function ComposerContext({
{
id: "cloud",
label: t(CONTEXT_LABELS.newCloudMachine),
// What one gives, as the environment picker's row says it.
detail: t("composer.newCloudMachineDetail"),
onSelect: () => onboardingConnect?.("cloud"),
},
]
: !started && onConnect && !connectOnlyWhenEmpty
? [
{ id: "ssh", label: t(CONTEXT_LABELS.connectSSH), onSelect: () => onConnect("ssh") },
{ id: "cloud", label: t(CONTEXT_LABELS.newCloudMachine), onSelect: () => onConnect("cloud") },
{
id: "cloud",
label: t(CONTEXT_LABELS.newCloudMachine),
detail: t("composer.newCloudMachineDetail"),
onSelect: () => onConnect("cloud"),
},
]
: undefined
}
Expand All @@ -475,14 +532,14 @@ export function ComposerContext({
}

function BranchPicker({ branch }: { branch: string }) {
const { control, anchor, open, setOpen, side } = useLocationPopover();
const { control, anchor, open, setOpen, side, flip } = useLocationPopover();
return (
<span ref={control} className="acpmux-location-picker">
<Menu open={open} onOpenChange={setOpen}>
<MenuButton className="acpmux-location-button ui-ghost ui-ghost-sm" label={t("changes.scope.branch")}>
<LocationFace icon={<BranchIcon />} value={branch} chevron />
</MenuButton>
<MenuPopup side={side} flip={false} anchor={anchor} className="acpmux-menu acpmux-location-menu" align="end">
<MenuPopup side={side} flip={flip} anchor={anchor} className="acpmux-menu acpmux-location-menu" align="end">
<MenuRadioGroup value={branch} onValueChange={() => undefined}>
<MenuRadioItem value={branch} disabled className="acpmux-menu-item">
<span className="acpmux-menu-text">
Expand Down Expand Up @@ -690,13 +747,13 @@ function LocationPicker({
/// The folder popover's last row, Choose folder…: the host's folder panel.
onBrowse?(): void;
/// Rows after the choices that run something instead of picking (the connect flows).
extras?: { id: string; label: string; onSelect(): void }[];
extras?: { id: string; label: string; detail?: string; onSelect(): void }[];
onPick(id: string): void;
/// The list opened, and every few seconds while it stays open (useMachineRefresh).
onOpen?(): void;
}) {
const [query, setQuery] = useState("");
const { control, anchor, open, setOpen, side } = useLocationPopover();
const { control, anchor, open, setOpen, side, flip } = useLocationPopover();
const changeOpen = (next: boolean) => {
setOpen(next);
if (!next) setQuery("");
Expand Down Expand Up @@ -766,7 +823,7 @@ function LocationPicker({
label={label}
className="acpmux-menu acpmux-location-menu"
side={side}
flip={false}
flip={flip}
>
<Combobox
suggestions={suggestions}
Expand Down Expand Up @@ -842,7 +899,14 @@ function LocationPicker({
extra.onSelect();
}}
>
{extra.label}
{extra.detail ? (
<span className="acpmux-menu-text">
<span className="acpmux-menu-label">{extra.label}</span>
<span className="acpmux-menu-description">{extra.detail}</span>
</span>
) : (
extra.label
)}
</button>
))}
{onBrowse && (
Expand Down
6 changes: 4 additions & 2 deletions webviews/src/agent-session/acpmux/EnvironmentPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ const CLOUD_STAGE_LABELS: Record<string, StringKey> = {
creating: "composer.envStarting",
booting: "composer.envStarting",
connecting: "composer.envStarting",
// A machine that failed to start says so, never "Not connected", which reads as a link to fix.
failed: "switch.failedShort",
};
function cloudMachineLabel(machine: CloudMachine): string {
return t(CLOUD_STAGE_LABELS[machine.stage] ?? "composer.envNotConnected");
Expand Down Expand Up @@ -140,7 +142,7 @@ export function EnvironmentPicker({
const [query, setQuery] = useState("");
const trigger = useRef<HTMLButtonElement>(null);
const escaped = useRef(false);
const { control, anchor, open, setOpen, side } = useLocationPopover();
const { control, anchor, open, setOpen, side, flip } = useLocationPopover();
const reset = () => {
setQuery("");
};
Expand Down Expand Up @@ -538,7 +540,7 @@ export function EnvironmentPicker({
label={label}
className="acpmux-menu acpmux-location-menu acpmux-folder-popover acpmux-env-popover"
side={side}
flip={false}
flip={flip}
finalFocus={false}
>
<Combobox
Expand Down
4 changes: 2 additions & 2 deletions webviews/src/agent-session/acpmux/FolderPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function FolderPopover({
const trigger = useRef<HTMLButtonElement>(null);
// Escape (not a blur) closes back to the strip's control.
const escaped = useRef(false);
const { control, anchor, open, setOpen, side } = useLocationPopover();
const { control, anchor, open, setOpen, side, flip } = useLocationPopover();
const press = usePopoverTrigger(open, (next) => {
setOpen(next);
if (!next) {
Expand Down Expand Up @@ -276,7 +276,7 @@ export function FolderPopover({
label={label}
className="acpmux-menu acpmux-location-menu acpmux-folder-popover"
side={side}
flip={false}
flip={flip}
finalFocus={false}
>
<Combobox
Expand Down
21 changes: 17 additions & 4 deletions webviews/src/agent-session/acpmux/cloudMachines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,27 @@ export function refreshCloudMachines(call: Call = postNative): Promise<void> {
}

/// Resumes or opens the machine (one click: the app checks the gesture and the machine), then
/// reads the list again so its new stage shows.
/// reads the list again so its new stage shows; a resume shows as starting at once.
export function runCloudMachine(machine: CloudMachine, call: Call = postNative): Promise<void> {
const method = cloudMachineAction(machine) === "resume" ? "cloudMachine.resume" : "cloudMachine.open";
// A list read already in flight was asked before the action: wait it out, then read again.
// A resume shows at once: the paused row reads Starting… while the app wakes the machine, not
// Paused · Resume until the next read (a second pick would ask again).
if (method === "cloudMachine.resume")
publish(machines.map((other) => (other.id === machine.id ? { ...other, stage: "requesting" } : other)));
// A list read already in flight was asked before the action: wait it out, then read again. A
// refused action shows as Couldn't start (the list would only say Paused again), so a pick that
// waits on the machine stops waiting instead of reading Starting… for ever.
let refused = false;
return call(method, { id: machine.id })
.catch(() => undefined)
.catch(() => {
refused = true;
})
.then(() => inFlight)
.then(() => refreshCloudMachines(call));
.then(() =>
refused
? publish(machines.map((other) => (other.id === machine.id ? { ...other, stage: "failed" } : other)))
: refreshCloudMachines(call),
);
}

/// After a pause or resume the app runs the action in the background and its list catches up
Expand Down
Loading
Loading