From 399db6f627f72159ea72ee7c9175142895bea5e3 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 02:40:57 +0000 Subject: [PATCH 1/8] feat(composer): share plus menu command registry --- .../acpmux/composerPlusRegistry.test.ts | 110 +++++++++++ .../acpmux/composerPlusRegistry.ts | 185 ++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 webviews/src/agent-session/acpmux/composerPlusRegistry.test.ts create mode 100644 webviews/src/agent-session/acpmux/composerPlusRegistry.ts diff --git a/webviews/src/agent-session/acpmux/composerPlusRegistry.test.ts b/webviews/src/agent-session/acpmux/composerPlusRegistry.test.ts new file mode 100644 index 000000000000..cc7b3b7c18d5 --- /dev/null +++ b/webviews/src/agent-session/acpmux/composerPlusRegistry.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from "bun:test"; +import { + COMPOSER_PLUS_MOTION, + COMPOSER_PLUS_SECTIONS, + composerPlusRegistry, + composerPlusSections, +} from "./composerPlusRegistry"; + +test("the plus registry exposes the Add and Files sections in command order", () => { + const rows = composerPlusRegistry({ + canChooseProject: true, + canSetGoal: true, + planMode: { id: "plan", name: "Plan mode", description: "Think through the work before editing" }, + }); + + expect(COMPOSER_PLUS_MOTION).toBe("none"); + expect(rows.map((row) => row.section)).toEqual([ + "add", + "add", + "add", + "add", + "add", + "add", + "files", + ]); + expect(rows.slice(0, 6).map((row) => row.name)).toEqual([ + "Files and folders", + "Attach a screenshot", + "Attach a window", + "Choose a project", + "Plan mode", + "Set a goal", + ]); + expect(rows.at(-1)?.name).toBe("Files and chats"); + expect(rows.every((row) => row.icon && row.description && row.action)).toBe(true); +}); + +test("capture and optional goal rows are omitted when the host lacks those capabilities", () => { + const rows = composerPlusRegistry({ canCapture: false, canSetGoal: false }); + expect(rows.map((row) => row.id)).toEqual(["add.files", "files.search"]); +}); + +test("skills and prompts reuse their slash command while agent commands stay in the slash menu", () => { + const command = { name: "review", description: "Review the current change", source: "skill" as const }; + const rows = composerPlusRegistry({ + commands: [ + command, + { name: "build", description: "Build the project", source: "agent" }, + { name: "$draw", description: "Draw a sketch", source: "prompt" }, + ], + }); + + const skills = rows.filter((row) => row.section === "add" && row.id.startsWith("add.skill.")); + expect(skills.map((row) => row.name)).toEqual(["/review", "/$draw"]); + expect(skills[0]?.action).toEqual({ kind: "slash", command }); + expect(rows.some((row) => row.name === "/build")).toBe(false); +}); + +test("host extensions keep their palette action and deduplicate stable ids", () => { + const rows = composerPlusRegistry({ + extensions: [ + { + id: "browser", + section: "tools", + name: "Browser", + description: "Control the in-app browser", + icon: "browser", + keywords: ["web"], + action: { kind: "command", id: "palette.openBrowser" }, + }, + { + id: "browser", + section: "tools", + name: "Browser duplicate", + description: "Should be ignored", + icon: "browser", + keywords: [], + action: { kind: "command", id: "other" }, + }, + { + id: "recent-1", + section: "sessions", + name: "Recent chat", + description: "Open a recent chat", + icon: "agent.chat", + keywords: ["session"], + action: { kind: "command", id: "palette.openChat" }, + }, + { + id: "tab-1", + section: "tabs", + name: "Browser tab", + description: "Attach an open tab", + icon: "browser", + keywords: ["tab"], + action: { kind: "command", id: "palette.attachTab" }, + }, + ], + }); + + expect(rows.filter((row) => row.id === "browser")).toHaveLength(1); + expect(rows.find((row) => row.id === "browser")?.action).toEqual({ + kind: "command", + id: "palette.openBrowser", + }); + expect([...composerPlusSections(rows).keys()]).toEqual([...COMPOSER_PLUS_SECTIONS]); + expect(composerPlusSections(rows).get("tools")?.map((row) => row.name)).toEqual(["Browser"]); + expect(composerPlusSections(rows).get("sessions")?.map((row) => row.name)).toEqual(["Recent chat"]); + expect(composerPlusSections(rows).get("tabs")?.map((row) => row.name)).toEqual(["Browser tab"]); +}); diff --git a/webviews/src/agent-session/acpmux/composerPlusRegistry.ts b/webviews/src/agent-session/acpmux/composerPlusRegistry.ts new file mode 100644 index 000000000000..a99b5dbd4d86 --- /dev/null +++ b/webviews/src/agent-session/acpmux/composerPlusRegistry.ts @@ -0,0 +1,185 @@ +import type { SlashCommand } from "./slashCommands"; + +/** + * The command palette rows exposed by the composer's + menu. + * + * The registry owns labels, descriptions, icons and stable action ids. Hosts provide the live + * rows for tools, sessions and tabs; the composer invokes the same action dispatcher used by the + * native palette instead of implementing a second command handler. + */ +export const COMPOSER_PLUS_SECTIONS = ["add", "tools", "sessions", "tabs", "files"] as const; +export type ComposerPlusSection = (typeof COMPOSER_PLUS_SECTIONS)[number]; + +export type ComposerPlusAction = + | { kind: "command"; id: string } + | { kind: "slash"; command: SlashCommand }; + +export type ComposerPlusEntry = { + id: string; + section: ComposerPlusSection; + name: string; + description: string; + icon: string; + keywords: readonly string[]; + action: ComposerPlusAction; +}; + +export type ComposerPlusExtension = Omit & { + section: Exclude; +}; + +export type ComposerPlusRegistryOptions = { + /** The commands advertised by the harness; skills and prompts become Add rows. */ + commands?: readonly SlashCommand[]; + /** A harness can omit screenshots/window capture when its capture bridge is unavailable. */ + canCapture?: boolean; + /** Whether this surface can select a project/cwd before sending. */ + canChooseProject?: boolean; + /** Whether this harness exposes a persistent goal command. */ + canSetGoal?: boolean; + /** Whether the composer can change its run mode from this menu. */ + planMode?: { id: string; name: string; description: string }; + /** Live rows from the command-palette registry for tools, sessions, tabs and file search. */ + extensions?: readonly ComposerPlusExtension[]; +}; + +const COMMANDS = { + attachFiles: "composer.attachFiles", + attachScreenshot: "composer.attachScreenshot", + attachWindow: "composer.attachWindow", + chooseProject: "composer.chooseProject", + planMode: "composer.planMode", + goal: "composer.goal", + searchFiles: "searchFiles", +} as const; + +const staticRows: readonly ComposerPlusEntry[] = [ + { + id: "add.files", + section: "add", + name: "Files and folders", + description: "Attach files or folders as context", + icon: "file", + keywords: ["attach", "upload", "folder", "files"], + action: { kind: "command", id: COMMANDS.attachFiles }, + }, +]; + +function extensionKey(entry: ComposerPlusEntry): string { + return entry.section + ":\\u0000" + entry.id; +} + +/** + * Builds the + menu in display order. Empty sections are omitted by consumers, while the returned + * section field keeps rows interoperable with the native command-palette section ordering. + */ +export function composerPlusRegistry(options: ComposerPlusRegistryOptions = {}): ComposerPlusEntry[] { + const rows: ComposerPlusEntry[] = [...staticRows]; + + if (options.canCapture !== false) { + rows.push( + { + id: "add.screenshot", + section: "add", + name: "Attach a screenshot", + description: "Capture the screen and attach it", + icon: "site.permission.camera", + keywords: ["capture", "screen", "image"], + action: { kind: "command", id: COMMANDS.attachScreenshot }, + }, + { + id: "add.window", + section: "add", + name: "Attach a window", + description: "Capture a window and attach it", + icon: "site.permission.windowmanagement", + keywords: ["capture", "window", "image"], + action: { kind: "command", id: COMMANDS.attachWindow }, + }, + ); + } + + if (options.canChooseProject) { + rows.push({ + id: "add.project", + section: "add", + name: "Choose a project", + description: "Pick the folder or cwd for this chat", + icon: "folder", + keywords: ["project", "folder", "cwd", "workspace"], + action: { kind: "command", id: COMMANDS.chooseProject }, + }); + } + + if (options.planMode) { + rows.push({ + id: "add.mode." + options.planMode.id, + section: "add", + name: options.planMode.name, + description: options.planMode.description, + icon: "mode.plan", + keywords: ["plan", "mode", "read", "planning"], + action: { kind: "command", id: COMMANDS.planMode }, + }); + } + + if (options.canSetGoal) { + rows.push({ + id: "add.goal", + section: "add", + name: "Set a goal", + description: "Keep pursuing a goal across turns", + icon: "goal", + keywords: ["goal", "objective", "task"], + action: { kind: "slash", command: { name: "goal", description: "Set the chat goal.", source: "cmux" } }, + }); + } + + for (const command of options.commands ?? []) { + if (command.source !== "skill" && command.source !== "prompt") continue; + const name = command.name.trim(); + if (!name) continue; + rows.push({ + id: "add.skill." + name.toLocaleLowerCase(), + section: "add", + name: "/" + name, + description: command.description || "Run this skill in the current chat", + icon: "sparkles", + keywords: [name, command.description], + action: { kind: "slash", command }, + }); + } + + rows.push({ + id: "files.search", + section: "files", + name: "Files and chats", + description: "Search files or chats to attach as context", + icon: "search", + keywords: ["files", "chats", "search", "context"], + action: { kind: "command", id: COMMANDS.searchFiles }, + }); + + const seen = new Set(); + const extensions = [...(options.extensions ?? [])]; + const all = [...rows, ...extensions]; + return all.filter((entry) => { + if (!entry.id || !entry.name.trim() || !entry.description.trim()) return false; + const key = extensionKey(entry); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +/** Group entries without changing the registry's explicit section and row order. */ +export function composerPlusSections(entries: readonly ComposerPlusEntry[]): Map { + const sections = new Map( + COMPOSER_PLUS_SECTIONS.map((section) => [section, []]), + ); + for (const entry of entries) sections.get(entry.section)!.push(entry); + return sections; +} + +/** The + menu is an instant command surface; it must never fade in. */ +export const COMPOSER_PLUS_MOTION = "none" as const; From 9a7efbd5de00b50d439c9c9d7628e142c79b261f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 02:41:26 +0000 Subject: [PATCH 2/8] cmux NEXT composer: search files, chats, and pages from the plus menu Agent-Session: https://cmux.com/deeplink/agent?id=aid-242cd3472a33c842da39 --- .../src/agent-session/acpmux/Composer.tsx | 48 ++++++++++++-- .../agent-session/acpmux/ComposerPickers.tsx | 66 ++++++++++++++++--- .../acpmux/Localizable.xcstrings | 5 ++ .../agent-session/acpmux/composer.gallery.ts | 34 ++++++++++ .../acpmux/newtab/NewTabScreen.gallery.ts | 25 +++++++ .../acpmux/newtab/NewTabScreen.test.tsx | 58 ++++++++++++++++ .../acpmux/newtab/NewTabScreen.tsx | 30 ++++++++- .../agent-session/acpmux/newtab/screen.css | 1 + webviews/src/agent-session/acpmux/styles.css | 3 + 9 files changed, 255 insertions(+), 15 deletions(-) diff --git a/webviews/src/agent-session/acpmux/Composer.tsx b/webviews/src/agent-session/acpmux/Composer.tsx index abf2c2b626dd..2f270ff7fbd1 100644 --- a/webviews/src/agent-session/acpmux/Composer.tsx +++ b/webviews/src/agent-session/acpmux/Composer.tsx @@ -32,6 +32,7 @@ import { type ComposerPermissionAccess, } from "./ComposerPickers"; import { FileSearch } from "./FileSearch"; +import { postNative } from "./native"; import { PromptFocus } from "./promptFocus"; import { ComposerBox, type ComposerBoxHandle } from "./ComposerBox"; import { Icon } from "./icons/Icon"; @@ -1866,8 +1867,11 @@ export function Composer({ button={} align="start" returnFocus={false} + searchPlaceholder={t("sidebar.searchPlaceholder")} + emptyLabel={t(COMPOSER_LABELS.noMatchingCommands)} sections={[ { + title: t(COMPOSER_LABELS.add), choices: [ { id: "attach", name: t(COMPOSER_LABELS.attach), icon: }, // A harness without images would refuse the capture after the whole pick. @@ -1886,9 +1890,6 @@ export function Composer({ ] : []), { id: "mention", name: t(COMPOSER_LABELS.mention), icon: , hint: "@" }, - ...(searchFiles - ? [{ id: "files", name: t("files.search"), icon: }] - : []), ...(commands?.length ? [ { @@ -1923,9 +1924,44 @@ export function Composer({ }) : id === "mention" ? mention() - : id === "files" - ? setFindingFiles(true) - : openCommands(), + : openCommands(), + }, + { + title: t("composer.plusFilesChats"), + choices: [ + ...(searchFiles + ? [ + { + id: "files", + name: t("files.search"), + description: t("files.hint"), + icon: , + }, + ] + : []), + { + id: "chats", + name: t("sidebar.search"), + description: t("composer.plusChatsHint"), + icon: , + }, + ], + onPick: (id) => { + if (id === "files") setFindingFiles(true); + else void postNative("action.run", { id: "agentPane.searchChats" }).catch(() => undefined); + }, + }, + { + title: t("composer.plusPages"), + choices: [ + { + id: "pages", + name: t("composer.plusSearchPages"), + description: t("composer.plusPagesHint"), + icon: , + }, + ], + onPick: () => void postNative("action.run", { id: "history.search" }).catch(() => undefined), }, ]} /> diff --git a/webviews/src/agent-session/acpmux/ComposerPickers.tsx b/webviews/src/agent-session/acpmux/ComposerPickers.tsx index b378f26400a0..a4105ca9fd6a 100644 --- a/webviews/src/agent-session/acpmux/ComposerPickers.tsx +++ b/webviews/src/agent-session/acpmux/ComposerPickers.tsx @@ -1555,6 +1555,8 @@ export function Picker({ returnFocus = true, heading, tooltip, + searchPlaceholder, + emptyLabel, }: { label: string; className: string; @@ -1569,11 +1571,16 @@ export function Picker({ /// What the menu holds, as the trigger's tooltip: only a trigger that draws an icon and no text /// passes one (a picker showing its choice's name needs none). tooltip?: string; + /// The + menu filters its sections in place. Other select-only pickers stay unchanged. + searchPlaceholder?: string; + emptyLabel?: string; }) { const [open, setOpen] = useState(false); const [active, setActive] = useState(0); + const [query, setQuery] = useState(""); const root = useRef(null); const trigger = useRef(null); + const searchField = useRef(null); const menu = useRef(null); const menuId = useId(); const menuStyle = useUiAnchor(trigger, menu, open, { side: "above", align: "start" }); @@ -1594,7 +1601,16 @@ export function Picker({ kind: "list", active: ".acpmux-menu-active", }); - const rows = sections.flatMap((section, s) => section.choices.map((choice) => ({ section: s, choice }))); + const matching = query.trim().toLocaleLowerCase(); + const visibleSections = sections.map((section) => ({ + ...section, + choices: matching + ? section.choices.filter((choice) => + `${choice.name} ${choice.description ?? ""}`.toLocaleLowerCase().includes(matching), + ) + : section.choices, + })); + const rows = visibleSections.flatMap((section, s) => section.choices.map((choice) => ({ section: s, choice }))); // A live update can shrink the list under the highlight. const selected = Math.min(active, Math.max(rows.length - 1, 0)); @@ -1617,13 +1633,22 @@ export function Picker({ // A mouse press is opening it (cg-composer click polish): the focus it moves here is not drawn. const byPointer = useRef(false); const show = () => { - const current = rows.findIndex((row) => row.choice.id === sections[row.section].current); + const current = sections + .flatMap((section, s) => section.choices.map((choice) => ({ section: s, choice }))) + .findIndex((row) => row.choice.id === sections[row.section].current); + setQuery(""); setActive(Math.max(current, 0)); 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. focusWithRing(trigger.current, byPointer.current ? { focusVisible: false } : undefined); }; + useEffect(() => { + if (!open || !searchPlaceholder) return; + // Browser click focus runs after the trigger handler; focus the field on the next frame. + const frame = requestAnimationFrame(() => searchField.current?.focus({ preventScroll: true })); + return () => cancelAnimationFrame(frame); + }, [open, searchPlaceholder]); const showRef = useRef(show); showRef.current = show; // Like a click, which takes focus off the prompt first: that closes the slash menu and restores the draft. @@ -1675,11 +1700,11 @@ export function Picker({ pick(selected); } // A button clicks on Space's keyup; pick there and cancel that click, or it would reopen the menu. - else if (event.key === " ") event.preventDefault(); + else if (event.key === " " && !searchPlaceholder) event.preventDefault(); else if (event.key === "Tab") setOpen(false); }; const keyUp = (event: React.KeyboardEvent) => { - if (open && event.key === " ") { + if (open && event.key === " " && !searchPlaceholder) { event.preventDefault(); pick(selected); } @@ -1699,7 +1724,7 @@ export function Picker({ ref={trigger} type="button" // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role - role="combobox" + role={searchPlaceholder ? undefined : "combobox"} className="acpmux-picker-button" data-menu={label} aria-label={label} @@ -1708,7 +1733,7 @@ export function Picker({ aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? menuId : undefined} - aria-activedescendant={open && rows.length > 0 ? `${menuId}-${selected}` : undefined} + aria-activedescendant={open && !searchPlaceholder && rows.length > 0 ? `${menuId}-${selected}` : undefined} onKeyDown={keyDown} onKeyUp={keyUp} {...press} @@ -1728,15 +1753,40 @@ export function Picker({ className={`ui-popup ui-glass acpmux-menu acpmux-menu-${align}`} data-side="above" > + {searchPlaceholder && ( + 0 ? `${menuId}-${selected}` : undefined} + aria-autocomplete="list" + placeholder={searchPlaceholder} + spellCheck={false} + autoComplete="off" + value={query} + onChange={(event) => { + setQuery(event.target.value); + setActive(0); + }} + onKeyDown={keyDown} + /> + )} {/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role */}
+ {rows.length === 0 && emptyLabel &&
{emptyLabel}
} {heading && ( )} - {sections.map((section, s) => { - const titled = section.title && sections.length > 1; + {visibleSections.map((section, s) => { + if (section.choices.length === 0) return null; + const titled = section.title && visibleSections.filter((group) => group.choices.length > 0).length > 1; return (
ctx.document.querySelector('.acpmux-composer-plus [data-value="attach"]')); }, }, + "add-menu-search-chats": { + checks: PLUS_OPENS, + note: "The + menu groups search actions; typing chats leaves the existing Search chats palette action highlighted.", + snapshot: chat(finished), + play: async (ctx) => { + await ctx.click({ selector: ".acpmux-composer-plus .acpmux-picker-button" }); + await ctx.waitFor(() => ctx.document.activeElement?.classList.contains("acpmux-menu-search") === true); + await ctx.type("chats"); + await ctx.waitFor(() => ctx.document.querySelectorAll(".acpmux-composer-plus [role='option']").length === 1); + if (!ctx.document.querySelector('.acpmux-composer-plus [data-value="chats"]')) + throw new Error("Search chats was not left in the filtered + menu"); + }, + }, + "add-menu-search-pages": { + checks: PLUS_OPENS, + note: "Typing pages leaves the existing visited-page search action, with no unrelated + rows.", + snapshot: chat(finished), + play: async (ctx) => { + await ctx.click({ selector: ".acpmux-composer-plus .acpmux-picker-button" }); + await ctx.waitFor(() => ctx.document.activeElement?.classList.contains("acpmux-menu-search") === true); + await ctx.type("pages"); + await ctx.waitFor(() => ctx.document.querySelectorAll(".acpmux-composer-plus [role='option']").length === 1); + if (!ctx.document.querySelector('.acpmux-composer-plus [data-value="pages"]')) + throw new Error("Search pages was not left in the filtered + menu"); + }, + }, // cx-ncc.71 (Codex parity): + > Attach Screenshot… and Attach Window… take a capture through the // host (macOS screencapture's own region or window pick) and attach it as an image chip. "attach-screenshot": { diff --git a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.gallery.ts b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.gallery.ts index fef800191c1c..3df18ac5d6c0 100644 --- a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.gallery.ts +++ b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.gallery.ts @@ -673,6 +673,31 @@ export default agentPaneEntry({ if (!input || !input.multiple) throw new Error("the New Tab attachment chooser is missing or single-file"); }, }, + "plus-search-all": { + note: "The New Tab + menu uses the chat menu's searchable, sectioned glass list for Add, Files and chats, and Pages.", + ready: newTab(), + snapshot: noChat(manySessions(3)), + play: async (ctx) => { + await ctx.click({ selector: ".acpmux-newtab-plus .acpmux-picker-button" }); + await ctx.waitFor(() => ctx.document.activeElement?.classList.contains("acpmux-menu-search") === true); + for (const id of ["attach", "chats", "pages"]) + if (!ctx.document.querySelector(`.acpmux-newtab-plus [data-value="${id}"]`)) + throw new Error(`New Tab + lost ${id}`); + }, + }, + "plus-search-pages": { + note: "Filtering the New Tab + menu to pages keeps one whole actionable row and its section.", + ready: newTab(), + snapshot: noChat(manySessions(3)), + play: async (ctx) => { + await ctx.click({ selector: ".acpmux-newtab-plus .acpmux-picker-button" }); + await ctx.waitFor(() => ctx.document.activeElement?.classList.contains("acpmux-menu-search") === true); + await ctx.type("pages"); + await ctx.waitFor(() => ctx.document.querySelectorAll(".acpmux-newtab-plus [role='option']").length === 1); + if (!ctx.document.querySelector('.acpmux-newtab-plus [data-value="pages"]')) + throw new Error("New Tab + lost Search pages while filtering"); + }, + }, // The widget view (Debug Settings `newTab.widgets`, Leo 2026-10-18): soft cards under the create // actions, from the page's own data. Projects: this Mac, then its recent projects, the one a chat // starts in checked. Needs you: the chats asking for input or that hit an error, each with ×. diff --git a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx index 59460f8e0b3d..26770daed1a0 100644 --- a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx +++ b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx @@ -81,6 +81,64 @@ test("New Tab exposes the chat composer attachment menu", async () => { expect(container.querySelector('.acpmux-newtab-plus [data-value="attach"]')).toBeTruthy(); }); +test("New Tab + filters sections and opens the existing chat and page search actions", async () => { + const calls: Array<{ method: string; params: Record }> = []; + let chats = 0; + Object.assign(dom!.window, { + webkit: { + messageHandlers: { + agentSession: { + postMessage: ({ method, params }: { method: string; params: Record }) => { + calls.push({ method, params }); + return Promise.resolve({ ok: true, value: {} }); + }, + }, + }, + }, + }); + await act(async () => { + root!.render( + {}} + onOpen={() => {}} + onSearch={() => {}} + onShell={() => {}} + onJump={() => {}} + onOpenSession={() => {}} + onShowAll={() => chats++} + />, + ); + }); + const add = container.querySelector(".acpmux-newtab-plus .acpmux-picker-button")!; + const filter = async (text: string) => { + await act(async () => add.click()); + const input = container.querySelector(".acpmux-newtab-plus .acpmux-menu-search")!; + await act(async () => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + expect(document.activeElement === input).toBe(true); + await act(async () => { + const set = Object.getOwnPropertyDescriptor(dom!.window.HTMLInputElement.prototype, "value")!.set!; + set.call(input, text); + input.dispatchEvent(new dom!.window.Event("input", { bubbles: true })); + }); + return input; + }; + const pageSearch = await filter("pages"); + expect(container.querySelectorAll(".acpmux-newtab-plus [role=option]").length).toBe(1); + expect(container.querySelector('.acpmux-newtab-plus [data-value="pages"]')).toBeTruthy(); + await act(async () => + pageSearch.dispatchEvent(new dom!.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })), + ); + expect(calls).toContainEqual({ method: "action.run", params: { id: "history.search" } }); + + const chatSearch = await filter("chats"); + expect(container.querySelectorAll(".acpmux-newtab-plus [role=option]").length).toBe(1); + await act(async () => + chatSearch.dispatchEvent(new dom!.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })), + ); + expect(chats).toBe(1); +}); + test("a chosen document uses the New Tab upload path and becomes sendable", async () => { const uploaded: File[][] = []; const asks: unknown[][] = []; diff --git a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx index 793bf6f5cb80..ea507ffb3248 100644 --- a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx +++ b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx @@ -31,7 +31,8 @@ import { ChatCards } from "./ChatCards"; import { ClosedList } from "./ClosedList"; import { OpenTabsList } from "./OpenTabsList"; import { type NowPlayingCommand, NowPlayingPlayer } from "./NowPlayingPlayer"; -import { ArrowUpIcon, PaperclipIcon, Picker, PlusIcon } from "../ComposerPickers"; +import { ArrowUpIcon, PaperclipIcon, Picker, PlusIcon, SearchIcon } from "../ComposerPickers"; +import { postNative } from "../native"; import { newTabStart, useNewTabChipModel } from "./chipDefaults"; import { useLastPick } from "../lastPick"; import { useDeviceChats } from "./deviceChats"; @@ -1138,13 +1139,40 @@ export function NewTabScreen(props: Props) { button={} align="start" returnFocus={false} + searchPlaceholder={t("sidebar.searchPlaceholder")} + emptyLabel={t("composer.noMatchingCommands")} sections={[ { + title: t("composer.add"), choices: [{ id: "attach", name: t("composer.attach"), icon: }], onPick: (id) => { if (id === "attach") chooser.current?.click(); }, }, + { + title: t("composer.plusFilesChats"), + choices: [ + { + id: "chats", + name: t("sidebar.search"), + description: t("composer.plusChatsHint"), + icon: , + }, + ], + onPick: () => props.onShowAll(), + }, + { + title: t("composer.plusPages"), + choices: [ + { + id: "pages", + name: t("composer.plusSearchPages"), + description: t("composer.plusPagesHint"), + icon: , + }, + ], + onPick: () => void postNative("action.run", { id: "history.search" }).catch(() => undefined), + }, ]} /> diff --git a/webviews/src/agent-session/acpmux/newtab/screen.css b/webviews/src/agent-session/acpmux/newtab/screen.css index 59f443ff74bd..8639378a5648 100644 --- a/webviews/src/agent-session/acpmux/newtab/screen.css +++ b/webviews/src/agent-session/acpmux/newtab/screen.css @@ -100,6 +100,7 @@ spanning the card, its top corners the template's --acpmux-strip-radius. Its wrapper only marks the page as touched, so it draws nothing of its own. */ .nt-composer { flex: none; display: flex; flex-direction: column; min-width: 0; } +.nt-box:has(.acpmux-newtab-plus [aria-expanded="true"]) { z-index: var(--layer-dropdown); } .nt-location { display: contents; } /* No page of this run knows the recent project yet (NewTabScreen projectPending): the folder chip keeps its place and names nothing until the daemon lists the chats, never "Choose folder" first. */ diff --git a/webviews/src/agent-session/acpmux/styles.css b/webviews/src/agent-session/acpmux/styles.css index bce540b96549..cd701e1b6722 100644 --- a/webviews/src/agent-session/acpmux/styles.css +++ b/webviews/src/agent-session/acpmux/styles.css @@ -118,6 +118,9 @@ .acpmux-menu-start{left:0} .acpmux-menu-end{right:0;transform-origin:var(--transform-origin,bottom right)} .acpmux-menu-section+.acpmux-menu-section{margin-top:6px;padding-top:6px;box-shadow:inset 0 .5px 0 var(--acpmux-composer-edge)} +.acpmux-menu-search{display:block;width:100%;height:var(--ui-row-height);margin-bottom:var(--ui-popup-padding);padding:0 var(--ui-row-padding-inline);box-sizing:border-box;border:0;border-radius:var(--ui-row-radius);background:var(--acpmux-menu-hover);color:var(--agent-text);font:inherit;outline:none} +.acpmux-menu-search::placeholder{color:var(--acpmux-composer-dim)} +.acpmux-menu-empty{padding:var(--ui-row-padding-inline);color:var(--acpmux-composer-dim)} .acpmux-menu-header{display:flex;align-items:center;min-height:24px;padding:0 8px;color:var(--acpmux-composer-dim);font-size:var(--text-detail);letter-spacing:var(--text-detail--letter-spacing)} .acpmux-menu-item{display:flex;align-items:center;gap:8px;min-height:var(--ui-row-height);padding:5px var(--ui-row-padding-inline);box-sizing:border-box;border-radius:var(--ui-row-radius)} .acpmux-project-browse{width:100%;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer} From ec298b1c203ec972b6251be8da49b6c47f0a11d9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 02:46:21 +0000 Subject: [PATCH 3/8] style(composer): normalize plus registry keys --- .../src/agent-session/acpmux/composerPlusRegistry.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/webviews/src/agent-session/acpmux/composerPlusRegistry.ts b/webviews/src/agent-session/acpmux/composerPlusRegistry.ts index a99b5dbd4d86..1175847e9ea1 100644 --- a/webviews/src/agent-session/acpmux/composerPlusRegistry.ts +++ b/webviews/src/agent-session/acpmux/composerPlusRegistry.ts @@ -66,7 +66,7 @@ const staticRows: readonly ComposerPlusEntry[] = [ ]; function extensionKey(entry: ComposerPlusEntry): string { - return entry.section + ":\\u0000" + entry.id; + return `${entry.section}:${entry.id}`; } /** @@ -113,7 +113,7 @@ export function composerPlusRegistry(options: ComposerPlusRegistryOptions = {}): if (options.planMode) { rows.push({ - id: "add.mode." + options.planMode.id, + id: `add.mode.${options.planMode.id}`, section: "add", name: options.planMode.name, description: options.planMode.description, @@ -140,9 +140,9 @@ export function composerPlusRegistry(options: ComposerPlusRegistryOptions = {}): const name = command.name.trim(); if (!name) continue; rows.push({ - id: "add.skill." + name.toLocaleLowerCase(), + id: `add.skill.${name.toLocaleLowerCase()}`, section: "add", - name: "/" + name, + name: `/${name}`, description: command.description || "Run this skill in the current chat", icon: "sparkles", keywords: [name, command.description], @@ -177,7 +177,7 @@ export function composerPlusSections(entries: readonly ComposerPlusEntry[]): Map const sections = new Map( COMPOSER_PLUS_SECTIONS.map((section) => [section, []]), ); - for (const entry of entries) sections.get(entry.section)!.push(entry); + for (const entry of entries) sections.get(entry.section)?.push(entry); return sections; } From 21f81db52138c67bd691d6b32b5362a2e8045d03 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 03:12:16 +0000 Subject: [PATCH 4/8] cmux NEXT composer: source plus search rows from registry --- .../src/agent-session/acpmux/Composer.tsx | 109 ++++++++++++------ .../acpmux/newtab/NewTabScreen.tsx | 64 +++++++--- 2 files changed, 119 insertions(+), 54 deletions(-) diff --git a/webviews/src/agent-session/acpmux/Composer.tsx b/webviews/src/agent-session/acpmux/Composer.tsx index 2f270ff7fbd1..94586a22530b 100644 --- a/webviews/src/agent-session/acpmux/Composer.tsx +++ b/webviews/src/agent-session/acpmux/Composer.tsx @@ -41,6 +41,7 @@ import { hasOtherChats, mentionInsert, mentionItems, mentionSelection, type Ment import type { ContextMenuItem } from "../../ui/ContextMenu"; import { openChipMenu } from "./composerChipMenu"; import type { Choice } from "./ComposerPickers"; +import { composerPlusRegistry, composerPlusSections } from "./composerPlusRegistry"; import type { FileSearchSource } from "./fileSearchModel"; import { commandArgs, type CmuxCommand } from "./cmuxCommands"; import { cliOnlyCommand } from "./cliOnlyCommands"; @@ -1655,6 +1656,47 @@ export function Composer({ ) : null; // Why a failed turn paused the queue: the paused note names it (queuePause.ts). const pauseReason = snapshot.summary?.queuePaused === true ? queuePauseReason(snapshot.rows) : undefined; + // Files/chats/pages actions come from the shared command-palette registry. The local labels + // remain translated while the stable command ids keep the + menu and palette on one path. + const plusRegistryEntries = composerPlusRegistry({ + extensions: [ + { + id: "files.chats", + section: "files", + name: t("sidebar.search"), + description: t("composer.plusChatsHint"), + icon: "search", + keywords: ["files", "chats", "search", "context"], + action: { kind: "command", id: "agentPane.searchChats" }, + }, + { + id: "files.pages", + section: "files", + name: t("composer.plusSearchPages"), + description: t("composer.plusPagesHint"), + icon: "search", + keywords: ["pages", "history", "search"], + action: { kind: "command", id: "history.search" }, + }, + ], + }); + const plusFileEntries = composerPlusSections(plusRegistryEntries).get("files") ?? []; + const fileSearchChoices = plusFileEntries + .filter((entry) => entry.id !== "files.pages" && (searchFiles || entry.id !== "files.search")) + .map((entry) => ({ + id: entry.id === "files.search" ? "files" : entry.id === "files.chats" ? "chats" : entry.id, + name: entry.id === "files.search" ? t("files.search") : entry.name, + description: entry.id === "files.search" ? t("files.hint") : entry.description, + icon: , + })); + const pageSearchChoices = plusFileEntries + .filter((entry) => entry.id === "files.pages") + .map((entry) => ({ + id: "pages", + name: entry.name, + description: entry.description, + icon: , + })); return (
, - }, - ] - : []), - { - id: "chats", - name: t("sidebar.search"), - description: t("composer.plusChatsHint"), - icon: , - }, - ], - onPick: (id) => { - if (id === "files") setFindingFiles(true); - else void postNative("action.run", { id: "agentPane.searchChats" }).catch(() => undefined); - }, - }, - { - title: t("composer.plusPages"), - choices: [ - { - id: "pages", - name: t("composer.plusSearchPages"), - description: t("composer.plusPagesHint"), - icon: , - }, - ], - onPick: () => void postNative("action.run", { id: "history.search" }).catch(() => undefined), - }, + ...(searchFiles && fileSearchChoices.length + ? [ + { + title: t("composer.plusFilesChats"), + choices: fileSearchChoices, + onPick: (id: string) => { + if (id === "files") setFindingFiles(true); + else void postNative("action.run", { id: "agentPane.searchChats" }).catch(() => undefined); + }, + }, + ] + : fileSearchChoices.some((choice) => choice.id === "chats") + ? [ + { + title: t("composer.plusFilesChats"), + choices: fileSearchChoices, + onPick: () => + void postNative("action.run", { id: "agentPane.searchChats" }).catch(() => undefined), + }, + ] + : []), + ...(pageSearchChoices.length + ? [ + { + title: t("composer.plusPages"), + choices: pageSearchChoices, + onPick: () => void postNative("action.run", { id: "history.search" }).catch(() => undefined), + }, + ] + : []), ]} /> )} diff --git a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx index ea507ffb3248..d9101e44f48f 100644 --- a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx +++ b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx @@ -32,6 +32,7 @@ import { ClosedList } from "./ClosedList"; import { OpenTabsList } from "./OpenTabsList"; import { type NowPlayingCommand, NowPlayingPlayer } from "./NowPlayingPlayer"; import { ArrowUpIcon, PaperclipIcon, Picker, PlusIcon, SearchIcon } from "../ComposerPickers"; +import { composerPlusRegistry, composerPlusSections } from "../composerPlusRegistry"; import { postNative } from "../native"; import { newTabStart, useNewTabChipModel } from "./chipDefaults"; import { useLastPick } from "../lastPick"; @@ -1116,6 +1117,46 @@ export function NewTabScreen(props: Props) { /// split draws the chat composer's box instead. const pill = template === "cards" && !chatBox; + const plusRegistryEntries = composerPlusRegistry({ + extensions: [ + { + id: "files.chats", + section: "files", + name: t("sidebar.search"), + description: t("composer.plusChatsHint"), + icon: "search", + keywords: ["files", "chats", "search", "context"], + action: { kind: "command", id: "agentPane.searchChats" }, + }, + { + id: "files.pages", + section: "files", + name: t("composer.plusSearchPages"), + description: t("composer.plusPagesHint"), + icon: "search", + keywords: ["pages", "history", "search"], + action: { kind: "command", id: "history.search" }, + }, + ], + }); + const plusFileEntries = composerPlusSections(plusRegistryEntries).get("files") ?? []; + const plusSearchChoices = plusFileEntries + .filter((entry) => entry.id !== "files.pages") + .map((entry) => ({ + id: entry.id === "files.search" ? "files" : entry.id === "files.chats" ? "chats" : entry.id, + name: entry.id === "files.search" ? t("files.search") : entry.name, + description: entry.id === "files.search" ? t("files.hint") : entry.description, + icon: , + })); + const plusPageChoices = plusFileEntries + .filter((entry) => entry.id === "files.pages") + .map((entry) => ({ + id: "pages", + name: entry.name, + description: entry.description, + icon: , + })); + const attachmentPicker = ( <> , - }, - ], - onPick: () => props.onShowAll(), + choices: plusSearchChoices, + onPick: (id) => { + if (id === "files") void postNative("action.run", { id: "searchFiles" }).catch(() => undefined); + else props.onShowAll(); + }, }, { title: t("composer.plusPages"), - choices: [ - { - id: "pages", - name: t("composer.plusSearchPages"), - description: t("composer.plusPagesHint"), - icon: , - }, - ], + choices: plusPageChoices, onPick: () => void postNative("action.run", { id: "history.search" }).catch(() => undefined), }, ]} From df822c29bdbe23dc947b03d958b8926048419c2b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 15:38:18 -0700 Subject: [PATCH 5/8] feat(composer): Search chats and Search pages from the + menu Leo: "likewise, the chatgpt desktop app, let's do their + symbol's capabiliities for our composer. we want to be having more command palette related capabilities and stff to be able to to be surfaced" The chat composer's + and the New Tab + gain a Search section: Search chats opens the palette's chats page (agentPane.searchChats) and Search pages opens its History search page (history.search). The agent pane now allows history.search (AgentPaneModel.paletteActions), so the row runs instead of being refused; a refusal shows as the composer's notice. New Tab has no file search, so it offers no Search files. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-d3e7b519f093e7e33e5f --- .../AgentPaneModel+Actions.swift | 6 ++- .../AgentPaneConnectActionTests.swift | 12 ++++++ webviews/src/agent-session/acpmux/App.tsx | 15 +++++++ .../src/agent-session/acpmux/Composer.tsx | 2 +- .../acpmux/Localizable.xcstrings | 3 ++ .../agent-session/acpmux/composer.gallery.ts | 6 ++- .../acpmux/composerPlusRegistry.test.ts | 31 +++++++++++++++ .../acpmux/composerPlusRegistry.ts | 30 ++++++++++++++ .../acpmux/newtab/NewTabScreen.gallery.ts | 8 +++- .../acpmux/newtab/NewTabScreen.test.tsx | 39 +++++++++++++++++++ .../acpmux/newtab/NewTabScreen.tsx | 22 +++++++++++ 11 files changed, 170 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift index 75c4c481b305..e5e4503d5018 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift @@ -12,6 +12,10 @@ extension AgentPaneModel { ] /// The arguments a connect action runs with. static let connectArguments: [String: [String: String]] = ["palette.openCloudPane": ["route": "/get"]] + /// The command palette pages any agent tab may open: the chats page (Show all, the + menu's + /// Search chats) and the History search page (the + menu's Search pages). They open the app's own + /// palette, as Cmd-K does, so they need no gesture credit. + static let paletteActions: Set = ["agentPane.searchChats", "history.search"] /// What runs when a connect is refused: signed out, cmux Cloud… starts sign-in. static let connectFallbacks: [String: String] = ["newCloudMachine": "palette.auth.signIn"] @@ -25,7 +29,7 @@ extension AgentPaneModel { /// no fallback that ran, is an `unavailable` failure, so the page can say so; one that ran and /// then stopped reports itself in the app (its refusal or toast). func runAction(_ id: String) -> [String: Any] { - guard id == "newTab.page" || id == "agentPane.searchChats" || Self.connectActions.contains(id), + guard id == "newTab.page" || Self.paletteActions.contains(id) || Self.connectActions.contains(id), let onRunAction else { return Self.unsupported("action.run") } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneConnectActionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneConnectActionTests.swift index c98009c5aca8..30f4ad5ef5a8 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneConnectActionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneConnectActionTests.swift @@ -60,6 +60,18 @@ struct AgentPaneConnectActionTests { #expect(ran == ["cloudMove", "cloudBringBack"]) } + /// The + menu's Search chats and Search pages open the command palette's chats and History + /// search pages without a gesture; an action outside the pane's table stays refused. + @Test func paletteSearchPagesRunWithoutAGesture() async { + let model = AgentPaneModel(host: NoHost()) + var ran: [String] = [] + model.onRunAction = { id, args in ran.append(id); #expect(args.isEmpty); return true } + #expect(await model.respond(to: .runAction("agentPane.searchChats"))["ok"] as? Bool == true) + #expect(await model.respond(to: .runAction("history.search"))["ok"] as? Bool == true) + #expect(await model.respond(to: .runAction("searchFiles"))["ok"] as? Bool != true) + #expect(ran == ["agentPane.searchChats", "history.search"]) + } + /// An action the app did not run (turned off, or not available for this chat) is a failure, so /// the page can say so instead of nothing happening. @Test func anActionTheAppDidNotRunIsAFailure() async { diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index a90b0437f9d4..1c7bcd939ab1 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -17,6 +17,7 @@ import React, { import { AnsweredBy, AuthorChip } from "./multiplayer/Faces"; import { LivePresenceStack, LiveTypingLine } from "./multiplayer/LivePresence"; import { flushSync } from "react-dom"; +import { composerPlusSearchExtensions, PLUS_SEARCH_ACTIONS } from "./composerPlusRegistry"; import { CONNECT_ACTIONS, savedEnvironmentHost, type ConnectKind, type OffloadKind } from "./environments"; import { chiefEnvelope } from "./conversation/chiefEnvelope"; import { restoreForkSource } from "./handoff/restoreForkSource"; @@ -6200,6 +6201,17 @@ function AcpmuxPane() { // Show all chats opens the command palette's chats page (agentPane.searchChats, decision K1: // one palette). The host pushes the live bindings through applyShortcuts, so labels follow a rebind. const showAllChats = () => void callNative("action.run", { id: "agentPane.searchChats" }).catch(() => undefined); + // The + menu's Search chats and Search pages (Leo: "the chatgpt desktop app, let's do their + + // symbol's capabiliities for our composer"): the command palette's chats page and its History + // search page, both app actions the pane may run (AgentPaneModel.paletteActions). A refusal is + // said as the composer's notice, never dropped. + const plusSearchExtensions = useMemo(() => composerPlusSearchExtensions(t), [t]); + const runPlusSearch = (id: string) => { + const name = id === PLUS_SEARCH_ACTIONS.pages ? t("composer.plusSearchPages") : t("sidebar.search"); + void callNative("action.run", { id }).catch(() => + directClient.current?.notice(translate("composer.offloadNotRun", { action: name })), + ); + }; const [shortcuts, setShortcuts] = useState({}); // SSH… opens Connect to Machine; New Cloud Machine opens the host's create flow (Lawrence 2026-10-06). const connectMachine = (kind: ConnectKind) => @@ -10142,6 +10154,8 @@ function AcpmuxPane() { onMode={(modeId) => callNative("chat.mode", { modeId })} // Without a folder there is nothing to search; the + menu leaves the item out. searchFiles={fileRoot ? searchFiles : undefined} + plusExtensions={plusSearchExtensions} + onPlusCommand={runPlusSearch} capture={captureScreen} upload={uploadFiles} // A chat on this Mac (as Sources' + below); the host refuses any other. @@ -10543,6 +10557,7 @@ function AcpmuxPane() { onJump={(target, id) => void callNative("tab.jump", { target, id })} onOpenSession={selectSession} onShowAll={showAllChats} + onPlusCommand={runPlusSearch} onBrowseProject={() => callNative<{ cwd?: string }>("project.browse") .then((result) => result?.cwd) diff --git a/webviews/src/agent-session/acpmux/Composer.tsx b/webviews/src/agent-session/acpmux/Composer.tsx index 73353f196b8b..ee1091c67646 100644 --- a/webviews/src/agent-session/acpmux/Composer.tsx +++ b/webviews/src/agent-session/acpmux/Composer.tsx @@ -1955,7 +1955,7 @@ export function Composer({ ? t("sidebar.sessions") : section === "tabs" ? (t as unknown as (key: string) => string)("newTab.openTabs") - : t("files.search"), + : t("sidebar.searchPlaceholder"), choices: entries.map((entry): Choice => ({ id: entry.id, name: entry.name, diff --git a/webviews/src/agent-session/acpmux/Localizable.xcstrings b/webviews/src/agent-session/acpmux/Localizable.xcstrings index 8178a838087d..8f60f04cc4ec 100644 --- a/webviews/src/agent-session/acpmux/Localizable.xcstrings +++ b/webviews/src/agent-session/acpmux/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage": "en", "strings": { + "composer.plusChatsHint": {"comment":"Composer + menu: search row","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Find an earlier conversation"}},"ar":{"stringUnit":{"state":"translated","value":"ابحث عن محادثة سابقة"}},"bs":{"stringUnit":{"state":"translated","value":"Pronađi raniji razgovor"}},"da":{"stringUnit":{"state":"translated","value":"Find en tidligere samtale"}},"de":{"stringUnit":{"state":"translated","value":"Eine frühere Unterhaltung finden"}},"es":{"stringUnit":{"state":"translated","value":"Buscar una conversación anterior"}},"fr":{"stringUnit":{"state":"translated","value":"Retrouver une conversation précédente"}},"it":{"stringUnit":{"state":"translated","value":"Trova una conversazione precedente"}},"ja":{"stringUnit":{"state":"translated","value":"以前のチャットを探す"}},"km":{"stringUnit":{"state":"translated","value":"ស្វែងរកការជជែកមុន"}},"ko":{"stringUnit":{"state":"translated","value":"이전 대화 찾기"}},"nb":{"stringUnit":{"state":"translated","value":"Finn en tidligere samtale"}},"pl":{"stringUnit":{"state":"translated","value":"Znajdź wcześniejszą rozmowę"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Encontre uma conversa anterior"}},"ru":{"stringUnit":{"state":"translated","value":"Найти прошлую беседу"}},"th":{"stringUnit":{"state":"translated","value":"ค้นหาการสนทนาก่อนหน้า"}},"tr":{"stringUnit":{"state":"translated","value":"Önceki bir sohbeti bul"}},"uk":{"stringUnit":{"state":"translated","value":"Знайти попередню розмову"}},"vi":{"stringUnit":{"state":"translated","value":"Tìm cuộc trò chuyện trước"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"查找之前的对话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"尋找先前的對話"}}}}, + "composer.plusSearchPages": {"comment":"Composer + menu: search row","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Search pages"}},"ar":{"stringUnit":{"state":"translated","value":"ابحث في الصفحات"}},"bs":{"stringUnit":{"state":"translated","value":"Pretraži stranice"}},"da":{"stringUnit":{"state":"translated","value":"Søg i sider"}},"de":{"stringUnit":{"state":"translated","value":"Seiten durchsuchen"}},"es":{"stringUnit":{"state":"translated","value":"Buscar páginas"}},"fr":{"stringUnit":{"state":"translated","value":"Rechercher des pages"}},"it":{"stringUnit":{"state":"translated","value":"Cerca pagine"}},"ja":{"stringUnit":{"state":"translated","value":"ページを検索"}},"km":{"stringUnit":{"state":"translated","value":"ស្វែងរកទំព័រ"}},"ko":{"stringUnit":{"state":"translated","value":"페이지 검색"}},"nb":{"stringUnit":{"state":"translated","value":"Søk i sider"}},"pl":{"stringUnit":{"state":"translated","value":"Szukaj stron"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Pesquisar páginas"}},"ru":{"stringUnit":{"state":"translated","value":"Искать страницы"}},"th":{"stringUnit":{"state":"translated","value":"ค้นหาหน้า"}},"tr":{"stringUnit":{"state":"translated","value":"Sayfalarda ara"}},"uk":{"stringUnit":{"state":"translated","value":"Шукати сторінки"}},"vi":{"stringUnit":{"state":"translated","value":"Tìm trang"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"搜索页面"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"搜尋頁面"}}}}, + "composer.plusPagesHint": {"comment":"Composer + menu: search row","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Find a visited page"}},"ar":{"stringUnit":{"state":"translated","value":"ابحث عن صفحة زرتها"}},"bs":{"stringUnit":{"state":"translated","value":"Pronađi posjećenu stranicu"}},"da":{"stringUnit":{"state":"translated","value":"Find en besøgt side"}},"de":{"stringUnit":{"state":"translated","value":"Eine besuchte Seite finden"}},"es":{"stringUnit":{"state":"translated","value":"Buscar una página visitada"}},"fr":{"stringUnit":{"state":"translated","value":"Retrouver une page visitée"}},"it":{"stringUnit":{"state":"translated","value":"Trova una pagina visitata"}},"ja":{"stringUnit":{"state":"translated","value":"訪問したページを探す"}},"km":{"stringUnit":{"state":"translated","value":"ស្វែងរកទំព័រដែលបានចូលមើល"}},"ko":{"stringUnit":{"state":"translated","value":"방문한 페이지 찾기"}},"nb":{"stringUnit":{"state":"translated","value":"Finn en besøkt side"}},"pl":{"stringUnit":{"state":"translated","value":"Znajdź odwiedzoną stronę"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Encontre uma página visitada"}},"ru":{"stringUnit":{"state":"translated","value":"Найти посещённую страницу"}},"th":{"stringUnit":{"state":"translated","value":"ค้นหาหน้าที่เคยเข้าชม"}},"tr":{"stringUnit":{"state":"translated","value":"Ziyaret edilen bir sayfayı bul"}},"uk":{"stringUnit":{"state":"translated","value":"Знайти відвідану сторінку"}},"vi":{"stringUnit":{"state":"translated","value":"Tìm trang đã truy cập"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"查找访问过的页面"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"尋找瀏覽過的頁面"}}}}, "chatMenu.forkTo": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Fork to…"}},"ar":{"stringUnit":{"state":"translated","value":"إنشاء فرع في…"}},"de":{"stringUnit":{"state":"translated","value":"Verzweigen nach…"}},"fr":{"stringUnit":{"state":"translated","value":"Créer une branche dans…"}},"es":{"stringUnit":{"state":"translated","value":"Crear rama en…"}},"ja":{"stringUnit":{"state":"translated","value":"分岐先…"}},"ko":{"stringUnit":{"state":"translated","value":"다음 위치로 분기…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"分支到…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"分支至…"}},"bs":{"stringUnit":{"state":"translated","value":"Napravi granu u…"}},"da":{"stringUnit":{"state":"translated","value":"Forgren til…"}},"it":{"stringUnit":{"state":"translated","value":"Crea un ramo in…"}},"km":{"stringUnit":{"state":"translated","value":"បំបែកសាខាទៅ…"}},"nb":{"stringUnit":{"state":"translated","value":"Forgren til…"}},"pl":{"stringUnit":{"state":"translated","value":"Utwórz odgałęzienie w…"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Criar ramificação em…"}},"ru":{"stringUnit":{"state":"translated","value":"Создать ветку в…"}},"th":{"stringUnit":{"state":"translated","value":"แยกสาขาไปยัง…"}},"tr":{"stringUnit":{"state":"translated","value":"Şuraya dallandır…"}},"uk":{"stringUnit":{"state":"translated","value":"Створити гілку в…"}},"vi":{"stringUnit":{"state":"translated","value":"Tạo nhánh tại…"}}}}, "composer.connectPhone": {"comment":"Agent pane: the environment picker's row under New environment that opens the Mobile Pairing page, so cmux on iPhone reaches this Mac (EnvironmentPicker.tsx)","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Connect Phone…"}},"ar":{"stringUnit":{"state":"translated","value":"ربط الهاتف…"}},"bs":{"stringUnit":{"state":"translated","value":"Poveži telefon…"}},"da":{"stringUnit":{"state":"translated","value":"Forbind telefon…"}},"de":{"stringUnit":{"state":"translated","value":"Telefon verbinden…"}},"es":{"stringUnit":{"state":"translated","value":"Conectar teléfono…"}},"fr":{"stringUnit":{"state":"translated","value":"Connecter un téléphone…"}},"it":{"stringUnit":{"state":"translated","value":"Collega telefono…"}},"ja":{"stringUnit":{"state":"translated","value":"電話を接続…"}},"km":{"stringUnit":{"state":"translated","value":"ភ្ជាប់ទូរសព្ទ…"}},"ko":{"stringUnit":{"state":"translated","value":"휴대폰 연결…"}},"nb":{"stringUnit":{"state":"translated","value":"Koble til telefon…"}},"pl":{"stringUnit":{"state":"translated","value":"Połącz telefon…"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Conectar celular…"}},"ru":{"stringUnit":{"state":"translated","value":"Подключить телефон…"}},"th":{"stringUnit":{"state":"translated","value":"เชื่อมต่อโทรศัพท์…"}},"tr":{"stringUnit":{"state":"translated","value":"Telefon Bağla…"}},"uk":{"stringUnit":{"state":"translated","value":"Підключити телефон…"}},"vi":{"stringUnit":{"state":"translated","value":"Kết nối điện thoại…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"连接手机…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"連接手機…"}}}}, "retry.running": {"comment":"Agent pane: short note after a failed subagent's or sub-session's line when acpmux refused a retry because it already runs again (subagents/SubagentDoneLine.tsx)","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Already running again"}},"ar":{"stringUnit":{"state":"needs_review","value":"يعمل مرة أخرى بالفعل"}},"bs":{"stringUnit":{"state":"needs_review","value":"Već ponovo radi"}},"da":{"stringUnit":{"state":"needs_review","value":"Kører allerede igen"}},"de":{"stringUnit":{"state":"needs_review","value":"Läuft bereits wieder"}},"es":{"stringUnit":{"state":"needs_review","value":"Ya se está ejecutando de nuevo"}},"fr":{"stringUnit":{"state":"needs_review","value":"Déjà relancé"}},"it":{"stringUnit":{"state":"needs_review","value":"Già di nuovo in esecuzione"}},"ja":{"stringUnit":{"state":"needs_review","value":"すでに再実行中です"}},"km":{"stringUnit":{"state":"needs_review","value":"កំពុងដំណើរការម្តងទៀតរួចហើយ"}},"ko":{"stringUnit":{"state":"needs_review","value":"이미 다시 실행 중"}},"nb":{"stringUnit":{"state":"needs_review","value":"Kjører allerede igjen"}},"pl":{"stringUnit":{"state":"needs_review","value":"Już działa ponownie"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Já está em execução novamente"}},"ru":{"stringUnit":{"state":"needs_review","value":"Уже снова выполняется"}},"th":{"stringUnit":{"state":"needs_review","value":"กำลังทำงานอีกครั้งแล้ว"}},"tr":{"stringUnit":{"state":"needs_review","value":"Zaten yeniden çalışıyor"}},"uk":{"stringUnit":{"state":"needs_review","value":"Уже знову виконується"}},"vi":{"stringUnit":{"state":"needs_review","value":"Đã chạy lại rồi"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"已在重新运行"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"已在重新執行"}}}}, diff --git a/webviews/src/agent-session/acpmux/composer.gallery.ts b/webviews/src/agent-session/acpmux/composer.gallery.ts index d083bde58237..7cf7186ad511 100644 --- a/webviews/src/agent-session/acpmux/composer.gallery.ts +++ b/webviews/src/agent-session/acpmux/composer.gallery.ts @@ -1606,13 +1606,17 @@ export default agentPaneEntry({ }, }, "add-menu": { - note: "Play: open +; Attach files or images comes first and opens the file chooser, and the menu rises out of +.", + note: "Play: open +; Attach files or images comes first and opens the file chooser, the Search section offers Search chats and Search pages, and the menu rises out of +.", snapshot: chat(finished, { commands: [{ name: "compact", description: "Clear conversation history but keep a summary in context" }], }), play: async (ctx) => { await ctx.click({ selector: ".acpmux-composer-plus .acpmux-picker-button" }); await ctx.waitFor(() => ctx.document.querySelector('.acpmux-composer-plus [data-value="add.files"]')); + // The Search section: Search chats and Search pages open the app's palette pages. + for (const id of ["agentPane.searchChats", "history.search"]) + if (!ctx.document.querySelector(`.acpmux-composer-plus [data-value="${id}"]`)) + throw new Error(`the + menu lost ${id}`); }, }, // cx-ncc.71 (Codex parity): + > Attach Screenshot… and Attach Window… take a capture through the diff --git a/webviews/src/agent-session/acpmux/composerPlusRegistry.test.ts b/webviews/src/agent-session/acpmux/composerPlusRegistry.test.ts index a561f2d41b3d..52042df28690 100644 --- a/webviews/src/agent-session/acpmux/composerPlusRegistry.test.ts +++ b/webviews/src/agent-session/acpmux/composerPlusRegistry.test.ts @@ -4,8 +4,10 @@ import { COMPOSER_PLUS_MOTION, COMPOSER_PLUS_SECTIONS, composerPlusRegistry, + composerPlusSearchExtensions, composerPlusSections, nativeComposerExtensions, + PLUS_SEARCH_ACTIONS, runComposerPlusAction, } from "./composerPlusRegistry"; @@ -177,3 +179,32 @@ test("native catalog rows keep their real action ids and reject malformed rows", ]); expect(rows[0]?.icon).toBe("browser"); }); + +test("Search chats and Search pages dispatch the app's palette actions through the extension handler", () => { + const rows = composerPlusRegistry({ extensions: composerPlusSearchExtensions() }); + const search = composerPlusSections(rows).get("files") ?? []; + expect(search.map((row) => row.name)).toEqual(["Search files", "Search chats", "Search pages"]); + const ran: string[] = []; + const commands: string[] = []; + for (const row of search) + expect( + runComposerPlusAction(row.action, { + command: (id) => commands.push(id), + extension: (id) => ran.push(id), + slash: () => {}, + }), + ).toBe(true); + // Search files stays the composer's own file finder; the other two are app actions the pane may run + // (AgentPaneModel.paletteActions), never ids the host refuses. + expect(commands).toEqual(["searchFiles"]); + expect(ran).toEqual([PLUS_SEARCH_ACTIONS.chats, PLUS_SEARCH_ACTIONS.pages]); + const swift = readFileSync( + new URL( + "../../../../Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift", + import.meta.url, + ), + "utf8", + ); + const allowed = /paletteActions: Set = \[([^\]]*)\]/.exec(swift)?.[1] ?? ""; + for (const id of Object.values(PLUS_SEARCH_ACTIONS)) expect(allowed).toContain(`"${id}"`); +}); diff --git a/webviews/src/agent-session/acpmux/composerPlusRegistry.ts b/webviews/src/agent-session/acpmux/composerPlusRegistry.ts index 715dba58132b..f9bb00a8d4fb 100644 --- a/webviews/src/agent-session/acpmux/composerPlusRegistry.ts +++ b/webviews/src/agent-session/acpmux/composerPlusRegistry.ts @@ -188,6 +188,36 @@ export function composerPlusRegistry(options: ComposerPlusRegistryOptions = {}): }); } +/** + * The app's search pages the + menu opens (the ChatGPT desktop app's + searches): the command + * palette's chats page and its History search page. Both ids are app actions an agent tab may run + * (AgentPaneModel.paletteActions); a host that cannot run them leaves these rows out. + */ +export const PLUS_SEARCH_ACTIONS = { chats: "agentPane.searchChats", pages: "history.search" } as const; + +export function composerPlusSearchExtensions(t: Translate = translate): ComposerPlusExtension[] { + return [ + { + id: PLUS_SEARCH_ACTIONS.chats, + section: "files", + name: t("sidebar.search"), + description: t("composer.plusChatsHint"), + icon: "agent.chat.list", + keywords: ["chats", "conversations", "search"], + action: { kind: "command", id: PLUS_SEARCH_ACTIONS.chats }, + }, + { + id: PLUS_SEARCH_ACTIONS.pages, + section: "files", + name: t("composer.plusSearchPages"), + description: t("composer.plusPagesHint"), + icon: "history", + keywords: ["pages", "history", "browser", "web", "search"], + action: { kind: "command", id: PLUS_SEARCH_ACTIONS.pages }, + }, + ]; +} + export function composerPlusSections( entries: readonly ComposerPlusEntry[], ): Map { diff --git a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.gallery.ts b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.gallery.ts index 0e1db189da63..c5a0219515b2 100644 --- a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.gallery.ts +++ b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.gallery.ts @@ -683,7 +683,7 @@ export default agentPaneEntry({ }, }, "attachment-menu": { - note: "The New Tab composer keeps the chat composer's leading + attachment control: opening it exposes the same Attach option without moving the field.", + note: "The New Tab composer keeps the chat composer's leading + attachment control: opening it exposes the same Attach option, then Search chats and Search pages, without moving the field.", ready: newTab(), snapshot: noChat(manySessions(3)), play: async (ctx) => { @@ -694,6 +694,12 @@ export default agentPaneEntry({ await ctx.waitFor(() => ctx.document.querySelector('.acpmux-newtab-plus [data-value="attach"]') !== null); const input = ctx.document.querySelector(".nt-composer .acpmux-attach-input"); if (!input || !input.multiple) throw new Error("the New Tab attachment chooser is missing or single-file"); + // The ChatGPT-style + searches (Search chats, Search pages); the page has no Search files. + for (const id of ["agentPane.searchChats", "history.search"]) + if (!ctx.document.querySelector(`.acpmux-newtab-plus [data-value="${id}"]`)) + throw new Error(`the New Tab + lost ${id}`); + if (ctx.document.querySelector('.acpmux-newtab-plus [data-value="files.search"]')) + throw new Error("the New Tab + offers Search files, which it cannot run"); }, }, // The widget view (Debug Settings `newTab.widgets`, Leo 2026-10-18): soft cards under the create diff --git a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx index 59460f8e0b3d..4dc166b56a3f 100644 --- a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx +++ b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx @@ -81,6 +81,45 @@ test("New Tab exposes the chat composer attachment menu", async () => { expect(container.querySelector('.acpmux-newtab-plus [data-value="attach"]')).toBeTruthy(); }); +test("New Tab + offers Search chats and Search pages as app actions, and no Search files", async () => { + const ran: string[] = []; + const render = async (onPlusCommand?: (id: string) => void) => + act(async () => { + root!.render( + {}} + onOpen={() => {}} + onSearch={() => {}} + onShell={() => {}} + onJump={() => {}} + onOpenSession={() => {}} + onShowAll={() => {}} + {...(onPlusCommand ? { onPlusCommand } : {})} + />, + ); + }); + await render(); + const add = () => container.querySelector(".acpmux-newtab-plus .acpmux-picker-button")!; + await act(async () => add().click()); + // A host that cannot run them gets no rows that would do nothing. + expect(container.querySelector('.acpmux-newtab-plus [data-value="history.search"]')).toBeNull(); + await act(async () => add().click()); + + await render((id) => ran.push(id)); + for (const id of ["agentPane.searchChats", "history.search"]) { + await act(async () => add().click()); + expect(container.querySelector('.acpmux-newtab-plus [data-value="searchFiles"]')).toBeNull(); + expect(container.querySelector('.acpmux-newtab-plus [data-value="files.search"]')).toBeNull(); + const row = container.querySelector(`.acpmux-newtab-plus [data-value="${id}"]`); + expect(row).toBeTruthy(); + await act(async () => { + row!.dispatchEvent(new dom!.window.MouseEvent("mousedown", { bubbles: true, cancelable: true })); + }); + } + expect(ran).toEqual(["agentPane.searchChats", "history.search"]); +}); + test("a chosen document uses the New Tab upload path and becomes sendable", async () => { const uploaded: File[][] = []; const asks: unknown[][] = []; diff --git a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx index 12264f4295a8..e52aa8897583 100644 --- a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx +++ b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx @@ -76,6 +76,7 @@ import { Icon } from "../icons/Icon"; import { type NewTabTranslate, useNt } from "./strings"; import { screenSections, type ScreenTemplate } from "./templates"; import { type Translate, useT } from "../i18n"; +import { composerPlusSearchExtensions } from "../composerPlusRegistry"; import { ChromeArea } from "../../../ui/HideableChrome"; import { Popover } from "../../../ui/Popover"; import { PromptFocus } from "../promptFocus"; @@ -129,6 +130,9 @@ export type NewTabScreenActions = { /// One page of All chats from the host (`chats.page`); without it the page shows no All chats list. loadChatsPage?: LoadChatsPage; onShowAll(): void; + /// The + menu's Search chats and Search pages: runs that app action (PLUS_SEARCH_ACTIONS). Without + /// it the + menu leaves both rows out. + onPlusCommand?(id: string): void; onInputReady?(token: string): void; /// The first user input reached the page (the host recycles only an untouched page, R81). onTouched?(): void; @@ -1141,6 +1145,8 @@ export function NewTabScreen(props: Props) { /// split draws the chat composer's box instead. const pill = template === "cards" && !split; + const onPlusCommand = props.onPlusCommand; + const plusSearchRows = useMemo(() => composerPlusSearchExtensions(t), [t]); const attachmentPicker = ( <> ({ + id: row.id, + name: row.name, + description: row.description, + icon: , + })), + onPick: (id: string) => onPlusCommand(id), + }, + ] + : []), ]} /> From e19ae98295b1323b6c24273a4c8510d3c4204a07 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 15:56:52 -0700 Subject: [PATCH 6/8] fix(newtab): pass the + search handler to the New Tab screen, not the page Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-d3e7b519f093e7e33e5f --- webviews/src/agent-session/acpmux/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 1c7bcd939ab1..e0dfe9b42f79 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -10517,6 +10517,7 @@ function AcpmuxPane() { } chips={registryChips ?? NewTabComposerChips} focusField={newTab.focusesField !== false} + onPlusCommand={runPlusSearch} {...withSubmitAfterHandshake( newTabScreenActions({ callNative, @@ -10557,7 +10558,6 @@ function AcpmuxPane() { onJump={(target, id) => void callNative("tab.jump", { target, id })} onOpenSession={selectSession} onShowAll={showAllChats} - onPlusCommand={runPlusSearch} onBrowseProject={() => callNative<{ cwd?: string }>("project.browse") .then((result) => result?.cwd) From 3b374e10d7854709d94c86f84d4c11d1e25931e6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 17:43:47 -0700 Subject: [PATCH 7/8] fix(newtab): say a refused + search on the page New Tab shows no transcript, so the chat's notice never reached it: the page now says the refusal over its composer until the next pick or edit. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-d3e7b519f093e7e33e5f --- webviews/src/agent-session/acpmux/App.tsx | 4 +++- .../acpmux/newtab/NewTabScreen.test.tsx | 15 +++++++++++- .../acpmux/newtab/NewTabScreen.tsx | 23 +++++++++++++++---- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index e0dfe9b42f79..d7f3e0a60fef 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -6212,6 +6212,8 @@ function AcpmuxPane() { directClient.current?.notice(translate("composer.offloadNotRun", { action: name })), ); }; + // New Tab shows no transcript, so the page says a refusal itself (NewTabScreen plusRefused). + const runNewTabPlusSearch = (id: string) => callNative("action.run", { id }); const [shortcuts, setShortcuts] = useState({}); // SSH… opens Connect to Machine; New Cloud Machine opens the host's create flow (Lawrence 2026-10-06). const connectMachine = (kind: ConnectKind) => @@ -10517,7 +10519,7 @@ function AcpmuxPane() { } chips={registryChips ?? NewTabComposerChips} focusField={newTab.focusesField !== false} - onPlusCommand={runPlusSearch} + onPlusCommand={runNewTabPlusSearch} {...withSubmitAfterHandshake( newTabScreenActions({ callNative, diff --git a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx index 4dc166b56a3f..6d705523d52e 100644 --- a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx +++ b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx @@ -106,7 +106,7 @@ test("New Tab + offers Search chats and Search pages as app actions, and no Sear expect(container.querySelector('.acpmux-newtab-plus [data-value="history.search"]')).toBeNull(); await act(async () => add().click()); - await render((id) => ran.push(id)); + await render(async (id) => ran.push(id)); for (const id of ["agentPane.searchChats", "history.search"]) { await act(async () => add().click()); expect(container.querySelector('.acpmux-newtab-plus [data-value="searchFiles"]')).toBeNull(); @@ -118,6 +118,19 @@ test("New Tab + offers Search chats and Search pages as app actions, and no Sear }); } expect(ran).toEqual(["agentPane.searchChats", "history.search"]); + expect(container.querySelector(".acpmux-switch-notice")).toBeNull(); + + // The app refused it: the page says so instead of nothing happening (New Tab shows no transcript). + await render(() => Promise.reject(new Error("unavailable"))); + await act(async () => add().click()); + const pages = container.querySelector('.acpmux-newtab-plus [data-value="history.search"]')!; + await act(async () => { + pages.dispatchEvent(new dom!.window.MouseEvent("mousedown", { bubbles: true, cancelable: true })); + }); + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + expect(container.querySelector(".acpmux-switch-notice [role=alert]")?.textContent).toBe( + "Search pages isn't available for this chat", + ); }); test("a chosen document uses the New Tab upload path and becomes sendable", async () => { diff --git a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx index e52aa8897583..718258011b21 100644 --- a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx +++ b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx @@ -130,9 +130,9 @@ export type NewTabScreenActions = { /// One page of All chats from the host (`chats.page`); without it the page shows no All chats list. loadChatsPage?: LoadChatsPage; onShowAll(): void; - /// The + menu's Search chats and Search pages: runs that app action (PLUS_SEARCH_ACTIONS). Without - /// it the + menu leaves both rows out. - onPlusCommand?(id: string): void; + /// The + menu's Search chats and Search pages: runs that app action (PLUS_SEARCH_ACTIONS); it + /// rejects when the app refused it, and the page says so. Without it the + menu leaves both rows out. + onPlusCommand?(id: string): Promise; onInputReady?(token: string): void; /// The first user input reached the page (the host recycles only an untouched page, R81). onTouched?(): void; @@ -1147,6 +1147,16 @@ export function NewTabScreen(props: Props) { const onPlusCommand = props.onPlusCommand; const plusSearchRows = useMemo(() => composerPlusSearchExtensions(t), [t]); + /// The + search the app refused (turned off, or not for this tab), said over the composer until + /// the next pick or the next edit of the prompt. + const [plusRefusal, setPlusRefusal] = useState<{ action: string; text: string }>(); + const plusRefused = plusRefusal && plusRefusal.text === text ? plusRefusal.action : undefined; + const runPlusSearch = (id: string) => { + setPlusRefusal(undefined); + const action = plusSearchRows.find((row) => row.id === id)?.name ?? id; + const asked = text; + void onPlusCommand?.(id).catch(() => setPlusRefusal({ action, text: asked })); + }; const attachmentPicker = ( <> , })), - onPick: (id: string) => onPlusCommand(id), + onPick: runPlusSearch, }, ] : []), @@ -1386,6 +1396,11 @@ export function NewTabScreen(props: Props) { {t(sandboxFailureKey(sandboxRefusal) ?? "sandbox.error.failed")} )} + {plusRefused && ( + + {t("composer.offloadNotRun", { action: plusRefused })} + + )} {/* Above the box: the project strip (Cards draws it under the field). */} {!pill && !locationInFooter &&
{folderStrip}
}
From 7b4f4fa3449bb734e0c888dc0843347b019dc109 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 17:48:59 -0700 Subject: [PATCH 8/8] test(newtab): type the + search handler as a promise Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-d3e7b519f093e7e33e5f --- webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx index 6d705523d52e..1d9b2c23120b 100644 --- a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx +++ b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.test.tsx @@ -83,7 +83,7 @@ test("New Tab exposes the chat composer attachment menu", async () => { test("New Tab + offers Search chats and Search pages as app actions, and no Search files", async () => { const ran: string[] = []; - const render = async (onPlusCommand?: (id: string) => void) => + const render = async (onPlusCommand?: (id: string) => Promise) => act(async () => { root!.render(