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
7 changes: 7 additions & 0 deletions webviews/src/agent-session/acpmux/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ import { queuePauseReason } from "./queuePause";
import { stopTurnFromAction } from "./stopTurnAction";
import { composerPlusRegistry, type ComposerPlusExtension } from "./composerPlusRegistry";
import { ComposerPlusMenu } from "./ComposerPlusMenu";
import { loadRecentCommandNames, recentCommands, rememberRecentCommand } from "./recentCommands";
import { openPicker } from "./pickerOpeners";

/// Composer copy. English defaults until the host passes localized labels, as the rest of the pane does today.
Expand Down Expand Up @@ -1225,13 +1226,17 @@ export function Composer({
// Emptied (a command run, a prompt sent, the text deleted): this pane no longer types.
if (!value.trim() && sessionId) onTyping?.("", sessionId);
};
// The commands picked last (any chat), for the + menu's few recent rows; read again as + opens.
const [recentCommandNames, setRecentCommandNames] = useState(loadRecentCommandNames);
const pick = (command: SlashCommand) => {
setRecentCommandNames(rememberRecentCommand(command.name));
const next = applyCommand(text, caret, command);
pendingCaret.current = next.caret;
edit(next.text, next.caret);
field.current?.focus();
};
const pickFromPlus = (command: SlashCommand) => {
setRecentCommandNames(rememberRecentCommand(command.name));
const existing = field.current?.value() ?? text;
const next = `/${command.name} ${existing}`;
pendingCaret.current = next.length;
Expand Down Expand Up @@ -2382,6 +2387,7 @@ export function Composer({
planning={planning}
entries={composerPlusRegistry({
commands,
recentCommands: recentCommands(recentCommandNames, commands),
translate: t,
extensions: plusExtensions,
canCapture: Boolean(capture && allowImages),
Expand Down Expand Up @@ -2415,6 +2421,7 @@ export function Composer({
}}
{...(onPlusCommand ? { onExtension: onPlusCommand } : {})}
onSlash={pickFromPlus}
onOpen={() => setRecentCommandNames(loadRecentCommandNames())}
/>
)}
</>
Expand Down
4 changes: 4 additions & 0 deletions webviews/src/agent-session/acpmux/ComposerPickers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1630,6 +1630,7 @@ export function Picker({
heading,
tooltip,
overBackdrop = false,
onOpen,
}: {
label: string;
className: string;
Expand All @@ -1647,6 +1648,8 @@ export function Picker({
/// Draws the menu outside every backdrop root around its button (backdropLayer), so its glass
/// frosts what is behind the composer instead of letting it show through sharp.
overBackdrop?: boolean;
/// Runs as the menu opens, before its rows draw (the + menu reads its recent commands again).
onOpen?: () => void;
}) {
const [open, setOpen] = useState(false);
// Where the menu draws when it must leave the composer's glass (overBackdrop): found on open.
Expand Down Expand Up @@ -1708,6 +1711,7 @@ export function Picker({
const current = rows.findIndex((row) => row.choice.id === sections[row.section].current);
setActive(Math.max(current, 0));
setLayer(overBackdrop ? backdropLayer(root.current) : null);
onOpen?.();
setOpen(true);
// WebKit doesn't focus a clicked button; the keys must reach the menu, not the prompt. After a
// key was typed WebKit draws a programmatic focus as keyboard focus, so a press says it is not.
Expand Down
118 changes: 118 additions & 0 deletions webviews/src/agent-session/acpmux/ComposerPlusMenu.gallery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// l10n-allow-file: gallery fixtures (synthetic skill names), not shipped UI.
// The composer's + menu with an agent that advertises many skills (Leo, 2026-11-01: "our + button
// results are still really bad"; his + listed every skill and slash command, off the screen). +
// lists attach, Plan, Goal and Mention, at most three recent commands and one "Commands and
// skills…" row that opens the slash list. Compare arms (`composer-plus-commands`) shows where the
// recents and the row stand; the variants vary the recents a person has.
import { agentPaneEntry, type AgentPaneVariant } from "../../gallery/format";
import { assistant, chat, summary, user } from "../../gallery/fixtures/acpmux";
import type { PlayContext } from "../../gallery/play";
import { composerPlusCommands } from "./composerPlusCommands.experiment";
import type { AcpmuxSnapshot } from "./model";
import type { SlashCommand } from "./slashCommands";

const RECENTS_KEY = "cmux.acpmux.recentSlashCommands";

const SKILLS = [
"agent-sdk",
"agents-md",
"aws-billing",
"aws-cdk",
"aws-iam",
"aws-networking",
"aws-sdk-js",
"cloudflare-workers",
"cloudflare-d1",
"durable-objects",
"github-actions",
"launch-checklist",
"sandbox",
"signing",
"turnstile",
"web-perf",
"wrangler",
"deep-research",
];

const COMMANDS: SlashCommand[] = [
{ name: "goal", description: "Set a goal for this chat", source: "agent" },
{ name: "compact", description: "Clear conversation history but keep a summary in context" },
{ name: "review", description: "Review the current diff" },
...SKILLS.map((name) => ({
name,
description: `Sample skill: ${name.replace(/-/g, " ")}, with a description past the menu's edge`,
source: "skill" as const,
})),
{ name: "cmux:add-lane", description: "Start a lane in cmux", source: "prompt" },
{ name: "cmux:create-pr", description: "Open a pull request from this chat", source: "prompt" },
];

const snapshot: AcpmuxSnapshot = ((base) => ({
...base,
summary: {
...base.summary!,
promptCapabilities: { image: true },
modes: {
currentModeId: "default",
availableModes: [
{ id: "default", name: "Ask before edits" },
{ id: "plan", name: "Plan" },
],
},
},
}))(
chat(
[
user("Add retries with backoff to the fetch helper", 10),
assistant("Done: GETs retry, POSTs only with a policy.", 9),
summary(9, { status: "completed" }),
],
{ commands: COMMANDS },
),
);

/** Opens + with `recents` as the person's recent commands (the menu reads them as it opens). */
function openWith(recents: readonly string[]) {
return async (ctx: PlayContext) => {
ctx.document.defaultView?.localStorage.setItem(RECENTS_KEY, JSON.stringify(recents));
await ctx.click({ selector: ".acpmux-composer-plus .acpmux-picker-button" });
await ctx.waitFor(() => ctx.document.querySelector('.acpmux-composer-plus [data-value="add.commands"]'));
};
}

function variant(note: string, recents: readonly string[]): AgentPaneVariant {
return { note, snapshot, native: { "composer.capture": null }, play: openWith(recents) };
}

export default agentPaneEntry({
id: "agent-pane.composer-plus",
title: "Composer + menu",
area: "Agent pane",
height: 620,
widths: { narrow: 400, normal: 760, wide: 760 },
covers: ["agent-session/acpmux/ComposerPlusMenu.tsx#ComposerPlusMenu"],
keywords: ["plus", "+", "skills", "commands", "slash", "recent"],
variants: {
"recent-commands": variant(
"Play: + in a chat with 23 skills and commands: attach, Plan, Goal, Mention, the three commands used last, then Commands and skills… (opens /), then the searches.",
["review", "aws-cdk", "goal", "cloudflare-workers", "wrangler"],
),
"one-recent": variant("Play: + after one command was used: it, then Commands and skills….", ["compact"]),
"no-recents": variant(
"Play: + before any command was used (or none the agent still lists): one Commands and skills… row, no command rows.",
["not-advertised-here"],
),
},
experiment: {
definition: composerPlusCommands,
setup: openWith(["review", "aws-cdk", "cloudflare-workers"]),
script: [
{
name: "Highlight Commands and skills…",
run: async (ctx) => {
await ctx.hover({ selector: '.acpmux-composer-plus [data-value="add.commands"]' });
},
},
],
},
});
62 changes: 42 additions & 20 deletions webviews/src/agent-session/acpmux/ComposerPlusMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
// 2026-10-31: "our + button is ass" / "inside chat and in the composer it's ass"). Its reference is
// ChatGPT's + menu: a short list in groups (attach first, then the composer's own actions, then the
// searches, then the host's catalog groups), one row per action with an icon, a title and a muted
// description on one line, in a bounded panel that scrolls inside. Skills are never rows here: one
// Skills… row opens the slash list, which searches them (composerPlusRegistry.ts).
// description on one line, in a bounded panel that scrolls inside. Skills and commands are never
// listed whole here (Leo, 2026-11-01: "our + button results are still really bad"): at most a few
// recent ones, then one "Commands and skills…" row that opens the slash list, which searches them
// all (composerPlusRegistry.ts, recentCommands.ts).
import type React from "react";
import { AtIcon, BuildIcon, PaperclipIcon, PlanIcon, Picker, PlusIcon, SearchIcon, SlashIcon } from "./ComposerPickers";
import type { Choice, Section } from "./ComposerPickers";
Expand All @@ -16,6 +18,8 @@ import {
import type { SlashCommand } from "./slashCommands";
import { useT } from "./i18n";
import { Icon } from "./icons/Icon";
import { experimentArm } from "../../experiments/experiment";
import { composerPlusCommands } from "./composerPlusCommands.experiment";

export type ComposerPlusMenuProps = {
/** The rows, from composerPlusRegistry, already narrowed to what this composer can run. */
Expand All @@ -28,8 +32,10 @@ export type ComposerPlusMenuProps = {
onCommand(id: string, modeId?: string): void;
/** A host action (Search chats, Search pages, the catalog's rows); none keeps the menu open. */
onExtension?(id: string): void;
/** A slash command row (Goal): it writes the command over the draft. */
/** A slash command row (Goal, a recent command): it writes the command over the draft. */
onSlash(command: SlashCommand): void;
/** The menu opens: the composer reads its recent commands again (another pane may have used one). */
onOpen?(): void;
};

function rowIcon(entry: ComposerPlusEntry, planning: boolean): React.ReactNode {
Expand All @@ -41,13 +47,25 @@ function rowIcon(entry: ComposerPlusEntry, planning: boolean): React.ReactNode {
return <Icon name={entry.icon} size={18} />;
}

export type ComposerPlusCommandsArm = keyof typeof composerPlusCommands.arms;

/** The commands group as the arm draws it: recents then the row (shipped), the row first, or the row alone. */
function arrangeCommands(rows: ComposerPlusEntry[], arm: ComposerPlusCommandsArm): ComposerPlusEntry[] {
const recents = rows.filter((entry) => entry.id !== "add.commands");
const row = rows.filter((entry) => entry.id === "add.commands");
if (arm === "row-only") return row.length ? row : recents;
return arm === "row-then-recents" ? [...row, ...recents] : [...recents, ...row];
}

/** The groups the menu draws, in COMPOSER_PLUS_SECTIONS order, empty ones left out. */
export function composerPlusMenuSections(
entries: readonly ComposerPlusEntry[],
planning = false,
arm: ComposerPlusCommandsArm = composerPlusCommands.defaultArm,
): { section: ComposerPlusSection; entries: ComposerPlusEntry[]; choices: Choice[] }[] {
return [...composerPlusSections(entries)].flatMap(([section, rows]) =>
rows.length
return [...composerPlusSections(entries)].flatMap(([section, all]) => {
const rows = section === "commands" ? arrangeCommands(all, arm) : all;
return rows.length
? [
{
section,
Expand All @@ -66,8 +84,8 @@ export function composerPlusMenuSections(
})),
},
]
: [],
);
: [];
});
}

export function ComposerPlusMenu({
Expand All @@ -77,22 +95,25 @@ export function ComposerPlusMenu({
onCommand,
onExtension,
onSlash,
onOpen,
}: ComposerPlusMenuProps) {
const t = useT();
// Untitled groups: a hairline splits them (styles.css), with no header band between.
const sections: Section[] = composerPlusMenuSections(entries, planning).map((group) => ({
choices: group.choices,
onPick: (id: string) => {
const entry = group.entries.find((row) => row.id === id);
if (!entry) return "keep";
const handled = runComposerPlusAction(entry.action, {
command: onCommand,
...(onExtension ? { extension: onExtension } : {}),
slash: onSlash,
});
return handled ? undefined : "keep";
},
}));
const sections: Section[] = composerPlusMenuSections(entries, planning, experimentArm(composerPlusCommands)).map(
(group) => ({
choices: group.choices,
onPick: (id: string) => {
const entry = group.entries.find((row) => row.id === id);
if (!entry) return "keep";
const handled = runComposerPlusAction(entry.action, {
command: onCommand,
...(onExtension ? { extension: onExtension } : {}),
slash: onSlash,
});
return handled ? undefined : "keep";
},
}),
);
return (
<Picker
label={t("composer.add")}
Expand All @@ -104,6 +125,7 @@ export function ComposerPlusMenu({
returnFocus={false}
overBackdrop
sections={sections}
{...(onOpen ? { onOpen } : {})}
/>
);
}
Loading
Loading