diff --git a/plans/cmux-next/coordination/react-pages.md b/plans/cmux-next/coordination/react-pages.md index 5add5e54af06..055363d19bfa 100644 --- a/plans/cmux-next/coordination/react-pages.md +++ b/plans/cmux-next/coordination/react-pages.md @@ -1,5 +1,6 @@ # Lane: react-pages +- 2026-10-29: agent pane native tab-row tools, web half: optional `tabRow.nativeRowTools` flag (positive height only), `pane.tabRowTools.set` through `callNative`, and `tabRowToolInvoked` host events. Quick actions and Inspector/Summary share the DOM callbacks; Needs You stays web until its rich popup can migrate without a detached anchor. Native renderer/tunable ships separately. No clearance CSS or default changes. - 2026-10-28 (`nx-transcript-expand-all-1028` for nx-chat): `ToolGroupRow` opens every item with one disclosure; command bodies and thoughts keep their own choices. Removed the five-call tail and matching `turns.ts` visibility assumption. `CommandRow`/`ToolRow` memoize by immutable item snapshot; `ChildRowView` folds each subagent's appended records once, preserving snapshots so a child stream skips unchanged siblings too. Stable `data-tool-group`, `data-tool-group-items`, and `data-tool-id` attributes support semantic gallery audits. Chevron geometry remains owned by `nx-transcript-chevrons-1028`; incoming-message/menu/action-row and rail surfaces are outside this change. - 2026-10-26 (`cmux/nx-file-chip-1025`, owner nx-file-chip-1025 for nx-chat): shared transcript `LinkChips.tsx`/`chips.css` uses regular prose metrics, no inset/fill, tinted inline icons; linked code labels share text metrics. Outside-project and other-host locality moves to existing translated tooltips; host open/confirmation/text-only policies remain the authority. `agent-pane.inline-chips` offers before, minimal (recommended), accent and hairline arms in light/dark and narrow widths. No native build or push. Overlaps #24015 only at the shared renderer; that PR owns path preview cards. diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 1e68bf24a3b0..0dc68248e3e4 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -11152,6 +11152,23 @@ function AcpmuxPane() { )} } - summary={inspector.on ? null : } + summary={inspector.on ? null : (native) => } menu={chatMenu} onMenuOpen={readTabState} expand={continuing && canContinue ? "continue" : undefined} diff --git a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.gallery.tsx b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.gallery.tsx index bf223ef98800..e7676fe78ec9 100644 --- a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.gallery.tsx +++ b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.gallery.tsx @@ -9,6 +9,8 @@ import { translate } from "../i18n"; import type { ChatMenuItem } from "./ChatHeaderTools"; import { PanelToggle } from "./PanelToggle"; import type { QuickAction } from "./quickActions"; +import type { NativeRowToolsHost } from "./useNativeRowTools"; +import { NeedsYouButton } from "../needsYou/NeedsYou"; type HeaderToolsProps = { treatment?: { size: number; gap: number }; @@ -25,6 +27,8 @@ type HeaderToolsProps = { onPreview?: (url: string | undefined, mode: "toggle" | "open") => void; onCustomize?: (list: readonly QuickAction[]) => void; summary: ReactNode; + inbox?: ReactNode; + nativeRowTools?: NativeRowToolsHost; menu: () => ChatMenuItem[]; onMenuOpen?: () => Promise; onMergePullRequest?: (pullRequest: { number: number; url: string }) => Promise<{ queued: boolean }>; @@ -341,6 +345,13 @@ const withMachine = (machine: ChatMenuItem[]): ChatMenuItem[] => { return [...rows.slice(0, archive), ...machine, "separator", ...rows.slice(archive)]; }; +const inbox = ( + undefined, goTo: () => undefined, ask: async () => undefined }} + /> +); + const base: HeaderToolsProps = { onTerminal: () => undefined, onSplitDown: () => undefined, @@ -405,6 +416,27 @@ export default componentEntry({ }, }, variants: { + "native-row-off": { + note: "The default web toolbar: Summary, Needs You, quick actions and the chat menu remain web controls.", + props: { ...base, inbox }, + }, + "native-row-tools": { + note: "Native row tools opt-in: Summary and quick-action icons move to the native host. Needs You keeps its web button and anchored rich list; the chat menu stays web. No default changes.", + props: { + ...base, + inbox, + nativeRowTools: { + enabled: true, + callNative: async () => undefined, + panel: { id: "summary", label: "Summary", toggled: false, run: () => undefined }, + }, + }, + play: async (ctx) => { + await ctx.waitFor(() => !ctx.document.querySelector("[data-quick-action], .acpmux-header-panel-toggle")); + ctx.find({ selector: ".acpmux-needs-you-button" }); + ctx.find({ role: "button", name: "Chat actions" }); + }, + }, "unified-compact": { note: "Chosen: one 24px target on a 26px pitch, 16px SF Symbols in the app, overflow last. The selected panel glyph uses accent ink; only the hovered button changes its hover ink. Fits the shared 28px tab row.", props: { diff --git a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx index 0a431efba11f..8cbf7f176701 100644 --- a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx +++ b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx @@ -11,6 +11,9 @@ import { useEffect, useRef, useState, type ReactNode, type MouseEvent, type Poin import { useT, type StringKey } from "../i18n"; import { Icon } from "../icons/Icon"; import { HeaderIcon } from "./HeaderIcon"; +import { HEADER_ACTIONS } from "./headerActions"; +import { headerQuickTools } from "./headerQuickTools"; +import { useNativeRowTools, type NativeRowToolsHost } from "./useNativeRowTools"; import { tooltipProps, useShortcut } from "../shortcuts"; import { registerPicker } from "../pickerOpeners"; import { Menu, MenuButton, MenuItem, MenuPopup, MenuSeparator, Submenu } from "../../../ui/Menu"; @@ -35,25 +38,7 @@ import { /// them, the Needs you inbox and the Inspector toggle included. export const HEADER_GLYPH = 16; -/// The app actions the header runs on its tab (CmuxNextAgentPane AgentPaneModel.headerActions). -export const HEADER_ACTIONS = { - terminal: "splitRight", - splitDown: "splitDown", - browser: "splitBrowserRight", - changes: "openDiffViewer", - /// Not an app action: the App opens the Files tree (CmuxNextApp AgentChatSplitToggles.files). - files: "files", - /// Not an app action: its own `pane.action` (CmuxNextAgentPane AgentPaneModel.previewAction). - preview: "preview", - rename: "renameTab", - pin: "palette.toggleTabPin", - moveRight: "moveSurfaceToPaneRight", - newWorkspace: "palette.moveTabToNewWorkspace", - newWindow: "tab.moveToNewWindow", - /// Not an app action: Start Agent's panel holds the chat (CmuxNextAgentPane AgentPaneHeaderHooks.moveToFloatingChat). - floatingChat: "floatingChat", - close: "closeTab", -} as const; +export { HEADER_ACTIONS } from "./headerActions"; export type ChatMenuItem = | "separator" @@ -72,16 +57,6 @@ export type ChatMenuItem = export const menuGroups = (...groups: ChatMenuItem[][]): ChatMenuItem[] => groups.filter((group) => group.length > 0).flatMap((group, index) => (index ? ["separator", ...group] : group)); -/// A preview's address as its card shows it: host, then the path and query when there is one. -function previewAddress(url: string): string { - try { - const parsed = new URL(url); - return `${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}${parsed.search}`; - } catch { - return url; - } -} - /// A submenu row: its label, and the keycap of `shortcutAction` when it has one. export type ChatMenuChild = { key: string; label: string; shortcutAction?: string; onSelect: () => void }; @@ -113,6 +88,7 @@ export function ChatHeaderTools({ onRemoveWorktree, inspector, inbox, + nativeRowTools, }: { /// Terminal and Browser run their split with the quick action's mode: the App opens it, or for /// `toggle` closes the split this button opened when it is still there. @@ -139,7 +115,7 @@ export function ChatHeaderTools({ onCustomize?: (list: readonly QuickAction[]) => void; /// Terminal and Browser split the chat's tab; Quick Chat's panel has none. tabTools?: boolean; - summary: ReactNode; + summary: ReactNode | ((native: boolean) => ReactNode); /// The menu's rows, read when it opens. menu: () => ChatMenuItem[]; /// Runs before the menu opens; the menu shows once it settles. @@ -166,6 +142,8 @@ export function ChatHeaderTools({ /// The Needs you inbox (needsYou/NeedsYou.tsx), right after the panel toggle, so the toggle stays /// the first of the top-right icons. inbox?: ReactNode; + /// Only the migrated icons; the host offers this opt-in while its native tab row is visible. + nativeRowTools?: NativeRowToolsHost; }) { const t = useT(); const terminalKey = useShortcut(HEADER_ACTIONS.terminal); @@ -180,8 +158,6 @@ export function ChatHeaderTools({ ? withShown(quick, "preview", true) : quick; const worktree = useWorktree(folder, readStatus, statusKey); - // A toggle is on while the split it opened is open; in open mode each click makes another, so none is. - const on = (action: string, mode: QuickActionMode) => (mode === "toggle" ? lit.includes(action) : undefined); // The right-clicked button, read as the menu opens. const target = useRef(undefined); const [menuTarget, setMenuTarget] = useState(); @@ -278,133 +254,48 @@ export function ChatHeaderTools({ return folded; }; const rows = () => menuGroups(foldedRows(), menu()); - // Browser without its own Preview button (the default): the chat's local page when it has one, so the - // globe opens what the chat is about, not a blank tab (Leo 2026-10-20). A toggle click closes whichever - // browser it opened: the page's preview or the plain split. - const previewInBrowser = - browserOpensPreview && onPreview !== undefined && !actions.some((action) => action.id === "preview"); - const browserRun = (mode: QuickActionMode) => { - // The page goes along, as the Preview button sent it: a preview the person closed since the last - // state push opens again instead of the click doing nothing. - if (previewInBrowser && on(HEADER_ACTIONS.preview, mode)) return onPreview(previewUrl, mode); - if (previewInBrowser && previewUrl && !on(HEADER_ACTIONS.browser, mode)) return onPreview(previewUrl, mode); - onBrowser(mode); + const tools = headerQuickTools({ + actions, + tabTools, + sideTabs, + lit, + browserOpensPreview, + hasChanges, + previewUrl, + onTerminal, + onSplitDown, + onBrowser, + onChanges, + onFiles, + onPreview, + onSideTabs, + t, + }); + const native = useNativeRowTools(nativeRowTools, tools); + const shortcuts: Partial> = { + terminal: terminalKey, + splitDown: splitDownKey, + browser: browserKey, + changes: changesKey, }; - const browserPressed = (mode: QuickActionMode) => - previewInBrowser && on(HEADER_ACTIONS.preview, mode) ? true : on(HEADER_ACTIONS.browser, mode); - const buttons = tabTools && actions.length > 0 && ( - <> - {actions.map(({ id, mode }) => - id === "newTab" ? ( - // One button whose label and icon change, so the next click lands on it again. - - ) : id === "changes" ? ( - // Hidden, not disabled, without changes (cx-qom0); an open diff keeps it, to close it here. - onChanges && (hasChanges || lit.includes(HEADER_ACTIONS.changes)) ? ( - - ) : null - ) : id === "files" ? ( - onFiles ? ( - - ) : null - ) : id === "preview" ? ( - // Hidden, not disabled, without a page (cx-qom0); an open preview keeps it, to close it here. - onPreview && (previewUrl || on(HEADER_ACTIONS.preview, mode)) ? ( - - ) : null - ) : id === "splitDown" ? ( - onSplitDown ? ( - - ) : null - ) : id === "terminal" ? ( - - ) : ( - - ), - )} - - ); + const buttons = + !native && + tabTools && + actions.length > 0 && + tools.map((tool) => ( + + )); return (
{worktree && } @@ -412,8 +303,8 @@ export function ChatHeaderTools({ )} {/* The panel toggle leads the icons, as in Claude and ChatGPT desktop. */} - {inspector} - {summary} + {!native && inspector} + {typeof summary === "function" ? summary(native) : !native && summary} {inbox} {buttons ? (
diff --git a/webviews/src/agent-session/acpmux/header/HeaderIcon.tsx b/webviews/src/agent-session/acpmux/header/HeaderIcon.tsx index 27587331c27e..901fbb9d19e6 100644 --- a/webviews/src/agent-session/acpmux/header/HeaderIcon.tsx +++ b/webviews/src/agent-session/acpmux/header/HeaderIcon.tsx @@ -4,25 +4,11 @@ import { Icon } from "../icons/Icon"; // Use the host's SF Symbol templates, as the native toolbar does. The bundled glyph keeps its // footprint while the template loads and in a browser gallery without a macOS symbol host. -const SYMBOLS: Readonly> = { - "view.list": "list.bullet", - "diff.file": "plusminus", - terminal: "terminal", - browser: "globe", - "action.more.vertical": "ellipsis", - "action.add": "plus", - "tunable.sidebar": "sidebar.right", - folder: "folder", - "media.play": "play", - "pane.split.down": "rectangle.split.1x2", - inbox: "tray", - "status.info": "info.circle", - "agent.session": "bubble.left.and.bubble.right", -}; +import { HEADER_SYMBOLS } from "./headerSymbols"; export function HeaderIcon({ name, size = 16, selected = false }: { name: string; size?: number; selected?: boolean }) { const symbols = useSyncExternalStore(subscribeHarnessIcons, harnessSymbolsSnapshot, harnessSymbolsSnapshot); - const symbol = SYMBOLS[name]; + const symbol = HEADER_SYMBOLS[name]; useEffect(() => { if (symbol) loadHarnessSymbol(symbol); }, [symbol]); diff --git a/webviews/src/agent-session/acpmux/header/headerActions.ts b/webviews/src/agent-session/acpmux/header/headerActions.ts new file mode 100644 index 000000000000..e4c95876e688 --- /dev/null +++ b/webviews/src/agent-session/acpmux/header/headerActions.ts @@ -0,0 +1,19 @@ +/// The app actions the header runs on its tab (CmuxNextAgentPane AgentPaneModel.headerActions). +export const HEADER_ACTIONS = { + terminal: "splitRight", + splitDown: "splitDown", + browser: "splitBrowserRight", + changes: "openDiffViewer", + /// Not an app action: the App opens the Files tree (CmuxNextApp AgentChatSplitToggles.files). + files: "files", + /// Not an app action: its own `pane.action` (CmuxNextAgentPane AgentPaneModel.previewAction). + preview: "preview", + rename: "renameTab", + pin: "palette.toggleTabPin", + moveRight: "moveSurfaceToPaneRight", + newWorkspace: "palette.moveTabToNewWorkspace", + newWindow: "tab.moveToNewWindow", + /// Not an app action: Start Agent's panel holds the chat (CmuxNextAgentPane AgentPaneHeaderHooks.moveToFloatingChat). + floatingChat: "floatingChat", + close: "closeTab", +} as const; diff --git a/webviews/src/agent-session/acpmux/header/headerQuickTools.ts b/webviews/src/agent-session/acpmux/header/headerQuickTools.ts new file mode 100644 index 000000000000..3832b042e3ef --- /dev/null +++ b/webviews/src/agent-session/acpmux/header/headerQuickTools.ts @@ -0,0 +1,154 @@ +import type { Translate } from "../i18n"; +import type { QuickAction, QuickActionId, QuickActionMode } from "./quickActions"; +import { HEADER_ACTIONS } from "./headerActions"; + +export interface HeaderQuickTool { + id: QuickActionId; + icon: string; + label: string; + title?: string; + pressed?: boolean; + run?: () => void; +} + +export interface HeaderQuickToolsInput { + actions: readonly QuickAction[]; + tabTools: boolean; + sideTabs: boolean; + lit: readonly string[]; + browserOpensPreview: boolean; + hasChanges: boolean; + previewUrl?: string; + onTerminal(mode: QuickActionMode): void; + onBrowser(mode: QuickActionMode): void; + onSplitDown?: (mode: QuickActionMode) => void; + onChanges?: (mode: QuickActionMode) => void; + onFiles?: (mode: QuickActionMode) => void; + onPreview?: (url: string | undefined, mode: QuickActionMode) => void; + onSideTabs?: () => void; + t: Translate; +} + +/// One list of visible quick actions and handlers, shared by the web and native buttons. +export function headerQuickTools(input: HeaderQuickToolsInput): HeaderQuickTool[] { + const { + actions, + tabTools, + sideTabs, + lit, + browserOpensPreview, + hasChanges, + previewUrl, + onTerminal, + onBrowser, + onSplitDown, + onChanges, + onFiles, + onPreview, + onSideTabs, + t, + } = input; + if (!tabTools) return []; + const on = (action: string, mode: QuickActionMode) => (mode === "toggle" ? lit.includes(action) : undefined); + const previewInBrowser = browserOpensPreview && onPreview !== undefined && !actions.some((a) => a.id === "preview"); + const address = () => { + try { + const url = new URL(previewUrl!); + return `${url.host}${url.pathname === "/" ? "" : url.pathname}${url.search}`; + } catch { + return previewUrl!; + } + }; + const previewTitle = () => (previewUrl ? t("header.previewAddress", { address: address() }) : undefined); + return actions.flatMap(({ id, mode }): HeaderQuickTool[] => { + switch (id) { + case "newTab": + return [ + { + id, + icon: sideTabs ? "tunable.sidebar" : "action.add", + label: t(sideTabs ? "header.hideTabs" : "header.newTab"), + pressed: sideTabs, + run: onSideTabs, + }, + ]; + case "changes": + return onChanges && (hasChanges || lit.includes(HEADER_ACTIONS.changes)) + ? [ + { + id, + icon: "diff.file", + label: t("header.changes"), + pressed: on(HEADER_ACTIONS.changes, mode), + run: () => onChanges(mode), + }, + ] + : []; + case "files": + return onFiles + ? [ + { + id, + icon: "folder", + label: t("header.files"), + pressed: on(HEADER_ACTIONS.files, mode), + run: () => onFiles(mode), + }, + ] + : []; + case "preview": + return onPreview && (previewUrl || on(HEADER_ACTIONS.preview, mode)) + ? [ + { + id, + icon: "media.play", + label: t("header.preview"), + title: previewTitle(), + pressed: on(HEADER_ACTIONS.preview, mode), + run: () => onPreview(previewUrl, mode), + }, + ] + : []; + case "splitDown": + return onSplitDown + ? [ + { + id, + icon: "pane.split.down", + label: t("header.splitDown"), + pressed: on(HEADER_ACTIONS.splitDown, mode), + run: () => onSplitDown(mode), + }, + ] + : []; + case "terminal": + return [ + { + id, + icon: "terminal", + label: t("header.terminal"), + pressed: on(HEADER_ACTIONS.terminal, mode), + run: () => onTerminal(mode), + }, + ]; + case "browser": + return [ + { + id, + icon: "browser", + label: t("header.browser"), + title: previewInBrowser ? previewTitle() : undefined, + pressed: previewInBrowser && on(HEADER_ACTIONS.preview, mode) ? true : on(HEADER_ACTIONS.browser, mode), + run: () => { + // The same toggle closes the preview or plain browser it opened. A page closed since + // the last state push still opens again, because its address goes along with the click. + if (previewInBrowser && on(HEADER_ACTIONS.preview, mode)) return onPreview(previewUrl, mode); + if (previewInBrowser && previewUrl && !on(HEADER_ACTIONS.browser, mode)) + return onPreview(previewUrl, mode); + onBrowser(mode); + }, + }, + ]; + } + }); +} diff --git a/webviews/src/agent-session/acpmux/header/headerSymbols.ts b/webviews/src/agent-session/acpmux/header/headerSymbols.ts new file mode 100644 index 000000000000..e711249aed60 --- /dev/null +++ b/webviews/src/agent-session/acpmux/header/headerSymbols.ts @@ -0,0 +1,16 @@ +/// The toolbar's SF Symbols, shared by the web templates and native button descriptors. +export const HEADER_SYMBOLS: Readonly> = { + "view.list": "list.bullet", + "diff.file": "plusminus", + terminal: "terminal", + browser: "globe", + "action.more.vertical": "ellipsis", + "action.add": "plus", + "tunable.sidebar": "sidebar.right", + folder: "folder", + "media.play": "play", + "pane.split.down": "rectangle.split.1x2", + inbox: "tray", + "status.info": "info.circle", + "agent.session": "bubble.left.and.bubble.right", +}; diff --git a/webviews/src/agent-session/acpmux/header/nativeRowTools.ts b/webviews/src/agent-session/acpmux/header/nativeRowTools.ts new file mode 100644 index 000000000000..4245510d77da --- /dev/null +++ b/webviews/src/agent-session/acpmux/header/nativeRowTools.ts @@ -0,0 +1,87 @@ +import { HEADER_SYMBOLS } from "./headerSymbols"; +import type { HeaderQuickTool } from "./headerQuickTools"; + +export interface NativeRowTool { + id: string; + symbol: string; + label: string; + enabled: boolean; + toggled: boolean; + badge?: string; + menu?: NativeRowMenuItem[]; +} +export interface NativeRowMenuItem { + id: string; + label: string; + enabled: boolean; + checked?: boolean; + role?: "destructive"; + children?: NativeRowMenuItem[]; +} +export interface NativeRowPanel { + id: "inspector" | "summary"; + label: string; + toggled: boolean; + run?: () => void; +} +export interface RowToolBinding { + tool: NativeRowTool; + run?: () => void; +} + +const characters = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +const boundedLabel = (label: string) => + label.length <= 64 + ? label + : Array.from(characters.segment(label)) + .slice(0, 64) + .map((part) => part.segment) + .join(""); + +/// Summary has no badge today. Needs You keeps its web trigger and rich anchored list in this step. +/// Only the existing icon buttons are described, in the same order as the DOM. +export function nativeRowToolBindings(quick: readonly HeaderQuickTool[], panel?: NativeRowPanel): RowToolBinding[] { + return [ + ...(panel + ? [ + { + tool: { + id: panel.id, + symbol: HEADER_SYMBOLS["view.list"]!, + label: panel.label, + enabled: !!panel.run, + toggled: panel.toggled, + }, + run: panel.run, + }, + ] + : []), + ...quick.map(({ id, icon, label, pressed, run }) => ({ + tool: { id, symbol: HEADER_SYMBOLS[icon]!, label, enabled: !!run, toggled: pressed === true }, + run, + })), + ] + .slice(0, 16) + .map(({ tool, run }) => ({ tool: { ...tool, label: boundedLabel(tool.label) }, run })); +} + +/// A single dispatch table: both sources own the same callbacks, and stale/unknown native ids do nothing. +export function dispatchNativeRowTool(value: unknown, bindings: readonly RowToolBinding[]): void { + if (!value || typeof value !== "object") return; + const event = value as { id?: unknown; menuItemId?: unknown }; + // No migrated button has a menu in this step. Never turn an unknown menu item into a button click. + if (typeof event.id !== "string" || event.menuItemId !== undefined) return; + const binding = bindings.find(({ tool }) => tool.id === event.id); + if (binding?.tool.enabled) binding.run?.(); +} + +const listeners = new Set<(value: unknown) => void>(); +export function receiveNativeRowToolInvocation(value: unknown): void { + for (const listener of listeners) listener(value); +} +export function onNativeRowToolInvocation(listener: (value: unknown) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/webviews/src/agent-session/acpmux/header/tabRow.ts b/webviews/src/agent-session/acpmux/header/tabRow.ts index 4a7bba347c97..abf09799eff2 100644 --- a/webviews/src/agent-session/acpmux/header/tabRow.ts +++ b/webviews/src/agent-session/acpmux/header/tabRow.ts @@ -8,6 +8,8 @@ import { useCallback, useEffect, useRef, useSyncExternalStore, type CSSPropertie export interface TabRow { readonly height: number; readonly trailingInset: number; + /// Debug chrome.tabs.nativeRowTools; absent/false keeps the existing web buttons. + readonly nativeRowTools?: boolean; } /// Space between the last tab and the tools, in the width the page reports: the tools' own pitch, not a gutter. @@ -23,9 +25,12 @@ const pixels = (value: unknown) => (typeof value === "number" && Number.isFinite /// offered row is a new one, even when it equals the last: the App offers it again after it forgot /// the tools' width (the page said ready again), and the page reports the width again for it. export function setTabRow(value: unknown): void { - const raw = (value ?? {}) as { height?: unknown; trailingInset?: unknown }; + const raw = (value ?? {}) as { height?: unknown; trailingInset?: unknown; nativeRowTools?: unknown }; const height = pixels(raw.height); - current = height > 0 ? { height, trailingInset: pixels(raw.trailingInset) } : NONE; + current = + height > 0 + ? { height, trailingInset: pixels(raw.trailingInset), nativeRowTools: raw.nativeRowTools === true } + : NONE; for (const listener of listeners) listener(); } diff --git a/webviews/src/agent-session/acpmux/header/useNativeRowTools.ts b/webviews/src/agent-session/acpmux/header/useNativeRowTools.ts new file mode 100644 index 000000000000..bac45bf69bc5 --- /dev/null +++ b/webviews/src/agent-session/acpmux/header/useNativeRowTools.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from "react"; +import type { HeaderQuickTool } from "./headerQuickTools"; +import { + dispatchNativeRowTool, + nativeRowToolBindings, + onNativeRowToolInvocation, + type NativeRowPanel, +} from "./nativeRowTools"; + +export interface NativeRowToolsHost { + enabled: boolean; + callNative(method: string, params: Record): Promise; + panel?: NativeRowPanel; +} + +/// Publish only changed values. Callback identities can change with a render without sending tools +/// again; invocation always uses the current handlers. Hidden headers/unmount clear the native list. +export function useNativeRowTools(host: NativeRowToolsHost | undefined, quick: readonly HeaderQuickTool[]): boolean { + const enabled = host?.enabled === true; + const callNative = host?.callNative; + const bindings = nativeRowToolBindings(quick, host?.panel); + const current = useRef(bindings); + current.current = bindings; + const payload = JSON.stringify({ tools: bindings.map(({ tool }) => tool) }); + const sent = useRef(undefined); + const [failed, setFailed] = useState(); + useEffect(() => { + if (!enabled || !callNative) return; + setFailed(undefined); + return () => { + sent.current = undefined; + void callNative("pane.tabRowTools.set", { tools: [] }).catch(() => undefined); + }; + }, [enabled, callNative]); + useEffect(() => { + if (!enabled || !callNative || sent.current === payload) return; + sent.current = payload; + let live = true; + void callNative("pane.tabRowTools.set", JSON.parse(payload)) + .then(() => { + if (live) setFailed(undefined); + }) + .catch(() => { + if (!live) return; + // An unsupported or unavailable host must not strand the person without buttons. + setFailed(payload); + void callNative("pane.tabRowTools.set", { tools: [] }).catch(() => undefined); + }); + return () => { + live = false; + }; + }, [enabled, callNative, payload]); + const active = enabled && failed !== payload; + useEffect(() => { + if (!active) return; + return onNativeRowToolInvocation((value) => dispatchNativeRowTool(value, current.current)); + }, [active]); + return active; +} diff --git a/webviews/src/agent-session/acpmux/pageHost.ts b/webviews/src/agent-session/acpmux/pageHost.ts index 06e7fcd21727..b374da47bd3e 100644 --- a/webviews/src/agent-session/acpmux/pageHost.ts +++ b/webviews/src/agent-session/acpmux/pageHost.ts @@ -16,6 +16,7 @@ import { setNowPlaying } from "./newtab/nowPlaying"; import { setReadAloudState } from "./conversation/readAloud"; import { setHeaderState } from "./header/headerState"; import { setTabRow } from "./header/tabRow"; +import { receiveNativeRowToolInvocation } from "./header/nativeRowTools"; import { setFloatingState } from "./floatingState"; import { configureNativeSidebarPreviewVariant } from "./sidebarPreview"; import { setSessionStatus } from "./sessionStatus"; @@ -140,6 +141,8 @@ export function applyHostEvent(event: HostEvent): void { return bridge?.applyCustomization(value); case "headerState": return setHeaderState(event.value); + case "tabRowToolInvoked": + return receiveNativeRowToolInvocation(event.value); case "tabRow": return setTabRow(event.value); case "floatingState": diff --git a/webviews/src/agent-session/acpmux/summary/SummaryButton.tsx b/webviews/src/agent-session/acpmux/summary/SummaryButton.tsx index f3b73a9aa696..b4a49b69586c 100644 --- a/webviews/src/agent-session/acpmux/summary/SummaryButton.tsx +++ b/webviews/src/agent-session/acpmux/summary/SummaryButton.tsx @@ -7,11 +7,13 @@ import type { SummaryCardControl } from "./cardState"; /// The header's summary button: shows or hides the chat's summary card (SummaryCard.tsx), which /// stays at the pane's top right until it is hidden here or by its own close button. It is the /// header's panel toggle (header/PanelToggle.tsx), the first of its icons. -export function SummaryButton({ card }: { card: SummaryCardControl }) { +export function SummaryButton({ card, native = false }: { card: SummaryCardControl; native?: boolean }) { const t = useT(); const label = t("summary.open"); // Automation and captures show it by its label, as a click does (see pickerOpeners.ts). const show = card.show; useEffect(() => registerPicker(label, show), [label, show]); + // Keep the picker registration when the native host owns only the visible button. + if (native) return null; return ; } diff --git a/webviews/test/chat-header-lit.test.tsx b/webviews/test/chat-header-lit.test.tsx index ce05a3ab86c4..f48973a7b54d 100644 --- a/webviews/test/chat-header-lit.test.tsx +++ b/webviews/test/chat-header-lit.test.tsx @@ -38,6 +38,9 @@ test("a toggle lights while the App reports its split open, and not in open mode const { ChatHeaderTools } = await import("../src/agent-session/acpmux/header/ChatHeaderTools"); const { configureQuickActions } = await import("../src/agent-session/acpmux/header/quickActions"); const { applyHostEvent } = await import("../src/agent-session/acpmux/pageHost"); + // Other header cases change these shared stores; this case starts from its own toggle state. + configureQuickActions({}); + applyHostEvent({ kind: "headerState", value: { lit: [], browserOpensPreview: true } }); root = createRoot(host); act(() => root!.render( {}} onBrowser={() => {}} summary={null} menu={() => []} />), @@ -60,4 +63,8 @@ test("a toggle lights while the App reports its split open, and not in open mode // In open mode every click opens another browser: there is no one panel to show as on. act(() => configureQuickActions({ quickActions: ["terminal", { id: "browser", mode: "open" }] })); expect(pressed()).toMatchObject({ Terminal: "false", Browser: null }); + act(() => { + configureQuickActions({}); + applyHostEvent({ kind: "headerState", value: { lit: [] } }); + }); }); diff --git a/webviews/test/native-row-tools.test.tsx b/webviews/test/native-row-tools.test.tsx new file mode 100644 index 000000000000..96b4e8e1e3b7 --- /dev/null +++ b/webviews/test/native-row-tools.test.tsx @@ -0,0 +1,414 @@ +// l10n-allow-file: native toolbar descriptors and synthetic test labels, not shipped UI. +import { afterEach, expect, test } from "bun:test"; +import { JSDOM } from "jsdom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { ChatHeaderTools } from "../src/agent-session/acpmux/header/ChatHeaderTools"; +import { HEADER_ACTIONS } from "../src/agent-session/acpmux/header/headerActions"; +import { headerQuickTools, type HeaderQuickToolsInput } from "../src/agent-session/acpmux/header/headerQuickTools"; +import { headerState, setHeaderState } from "../src/agent-session/acpmux/header/headerState"; +import { + dispatchNativeRowTool, + nativeRowToolBindings, + type NativeRowTool, +} from "../src/agent-session/acpmux/header/nativeRowTools"; +import { configureQuickActions, quickActions } from "../src/agent-session/acpmux/header/quickActions"; +import { setTabRow, useTabRow, type TabRow } from "../src/agent-session/acpmux/header/tabRow"; +import { useNativeRowTools } from "../src/agent-session/acpmux/header/useNativeRowTools"; +import { NeedsYouButton } from "../src/agent-session/acpmux/needsYou/NeedsYou"; +import { applyHostEvent } from "../src/agent-session/acpmux/pageHost"; +import { translate } from "../src/agent-session/acpmux/i18n"; +import { openPicker } from "../src/agent-session/acpmux/pickerOpeners"; +import { SummaryButton } from "../src/agent-session/acpmux/summary/SummaryButton"; + +const ids = ["newTab", "changes", "files", "preview", "splitDown", "terminal", "browser"] as const; +function quickInput(calls: string[]): HeaderQuickToolsInput { + return { + actions: ids.map((id) => ({ id, mode: "toggle" })), + tabTools: true, + sideTabs: true, + lit: [HEADER_ACTIONS.changes, HEADER_ACTIONS.files, HEADER_ACTIONS.terminal], + browserOpensPreview: true, + hasChanges: true, + previewUrl: "http://localhost:5173/", + onTerminal: (mode) => void calls.push(`terminal:${mode}`), + onBrowser: (mode) => void calls.push(`browser:${mode}`), + onSplitDown: (mode) => void calls.push(`splitDown:${mode}`), + onChanges: (mode) => void calls.push(`changes:${mode}`), + onFiles: (mode) => void calls.push(`files:${mode}`), + onPreview: (url, mode) => void calls.push(`preview:${mode}:${url}`), + onSideTabs: () => void calls.push("newTab"), + t: (key) => key, + }; +} + +test("native descriptors preserve quick-action order, toolbar symbols, state and panel semantics", () => { + const input = quickInput([]); + const bindings = nativeRowToolBindings(headerQuickTools(input), { + id: "summary", + label: "Chat summary", + toggled: true, + run: () => {}, + }); + expect(bindings.map(({ tool }) => tool.id)).toEqual(["summary", ...ids]); + expect(bindings.map(({ tool }) => tool.symbol)).toEqual([ + "list.bullet", + "sidebar.right", + "plusminus", + "folder", + "play", + "rectangle.split.1x2", + "terminal", + "globe", + ]); + expect(bindings.map(({ tool }) => tool.toggled)).toEqual([true, true, true, true, false, false, true, false]); + expect(bindings.every(({ tool }) => tool.enabled)).toBe(true); + expect(bindings[0]!.tool).not.toHaveProperty("badge"); + expect(bindings.some(({ tool }) => tool.id === "needsYou")).toBe(false); + const disabled = nativeRowToolBindings(headerQuickTools({ ...input, onSideTabs: undefined }), { + id: "inspector", + label: "Session Info", + toggled: false, + }); + expect(disabled[0]!.tool).toMatchObject({ id: "inspector", enabled: false, toggled: false }); + expect(disabled[1]!.tool.enabled).toBe(false); + const open = nativeRowToolBindings(headerQuickTools({ ...input, actions: [{ id: "terminal", mode: "open" }] })); + expect(open[0]!.tool.toggled).toBe(false); + expect(nativeRowToolBindings(headerQuickTools({ ...input, sideTabs: false }))[0]!.tool.symbol).toBe("plus"); +}); + +test("every native id dispatches the shared handler; invalid, disabled and menu invocations do nothing", () => { + const calls: string[] = []; + const bindings = nativeRowToolBindings(headerQuickTools(quickInput(calls)), { + id: "summary", + label: "Summary", + toggled: false, + run: () => void calls.push("summary"), + }); + for (const { tool } of bindings) dispatchNativeRowTool({ id: tool.id }, bindings); + expect(calls).toEqual([ + "summary", + "newTab", + "changes:toggle", + "files:toggle", + "preview:toggle:http://localhost:5173/", + "splitDown:toggle", + "terminal:toggle", + "browser:toggle", + ]); + const disabled = [{ tool: { ...bindings[0]!.tool, enabled: false }, run: () => void calls.push("disabled") }]; + dispatchNativeRowTool({ id: "summary" }, disabled); + for (const value of [ + null, + undefined, + true, + "terminal", + {}, + { id: 7 }, + { id: "missing" }, + { id: "terminal", menuItemId: "x" }, + { id: "terminal", menuItemId: null }, + ]) + dispatchNativeRowTool(value, bindings); + expect(calls).toHaveLength(8); + dispatchNativeRowTool( + { id: "inspector" }, + nativeRowToolBindings([], { + id: "inspector", + label: "Session Info", + toggled: true, + run: () => void calls.push("inspector"), + }), + ); + expect(calls.at(-1)).toBe("inspector"); +}); + +test("conditional quick actions and Browser's preview delegation keep the web behavior", () => { + const calls: string[] = []; + const input = quickInput(calls); + expect(headerQuickTools({ ...input, tabTools: false })).toEqual([]); + expect( + headerQuickTools({ + ...input, + hasChanges: false, + previewUrl: undefined, + lit: [], + onFiles: undefined, + onSplitDown: undefined, + }).map(({ id }) => id), + ).toEqual(["newTab", "terminal", "browser"]); + const browser = headerQuickTools({ + ...input, + actions: [{ id: "browser", mode: "toggle" }], + lit: [HEADER_ACTIONS.preview], + }); + expect(nativeRowToolBindings(browser)[0]!.tool.toggled).toBe(true); + dispatchNativeRowTool({ id: "browser" }, nativeRowToolBindings(browser)); + expect(calls).toEqual(["preview:toggle:http://localhost:5173/"]); +}); + +let dom: JSDOM | undefined; +let root: Root | undefined; +const scope = globalThis as Record; +const saved = new Map(); +let previousHeader: ReturnType | undefined; +let previousQuick: ReturnType | undefined; +let previousTabRow: TabRow | undefined; +function mount() { + previousHeader = headerState(); + previousQuick = quickActions(); + dom = new JSDOM("
", { + url: "http://localhost/", + pretendToBeVisual: true, + }); + const replacements = { + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + Node: dom.window.Node, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + MutationObserver: dom.window.MutationObserver, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + IS_REACT_ACT_ENVIRONMENT: true, + }; + for (const [key, value] of Object.entries(replacements)) { + saved.set(key, { had: key in scope, value: scope[key] }); + scope[key] = value; + } + configureQuickActions({ quickActions: ["terminal", "browser"] }); + setHeaderState({ lit: [], pullRequest: null, browserOpensPreview: true }); + const container = dom.window.document.getElementById("root")!; + root = createRoot(container); + return container; +} + +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; + if (previousQuick) configureQuickActions({ quickActions: previousQuick }); + if (previousHeader) setHeaderState({ ...previousHeader, pullRequest: previousHeader.pullRequest ?? null }); + previousQuick = undefined; + if (previousTabRow) setTabRow(previousTabRow); + previousTabRow = undefined; + previousHeader = undefined; + dom?.window.close(); + dom = undefined; + for (const [key, { had, value }] of saved) { + if (had) scope[key] = value; + else delete scope[key]; + } + saved.clear(); +}); + +test("host events switch the header to native tools and invoke the same web handlers", async () => { + const container = mount(); + const sent: { method: string; tools: NativeRowTool[] }[] = []; + const called: string[] = []; + const callNative = async (method: string, params: Record) => { + sent.push({ method, tools: params.tools as NativeRowTool[] }); + }; + function Header({ generation }: { generation: string }) { + const row = useTabRow(); + previousTabRow ??= row; + const panel = () => void called.push(`summary:${generation}`); + return ( + void called.push(`terminal:${generation}`)} + onBrowser={() => void called.push(`browser:${generation}`)} + summary={(native) => ( + {}, + }} + /> + )} + inbox={ + {}, + ask: async () => {}, + goTo: () => {}, + }} + /> + } + menu={() => [ + { + key: "rename", + label: "Rename", + icon: "action.edit", + onSelect: () => {}, + }, + ]} + nativeRowTools={{ + enabled: row.height > 0 && row.nativeRowTools === true, + callNative, + panel: { + id: "summary", + label: "Chat summary", + toggled: false, + run: panel, + }, + }} + /> + ); + } + const render = async (enabled: boolean, generation: string) => { + await act(async () => root!.render(
)); + await act(async () => + applyHostEvent({ + kind: "tabRow", + value: { height: 28, nativeRowTools: enabled }, + }), + ); + }; + await render(false, "off"); + expect(sent).toHaveLength(0); + expect(container.querySelectorAll("[data-quick-action]")).toHaveLength(2); + expect(container.querySelector(".acpmux-summary-button")).not.toBeNull(); + act(() => { + container.querySelector('[data-quick-action="terminal"]')!.click(); + container.querySelector(".acpmux-summary-button")!.click(); + }); + expect(called).toEqual(["terminal:off", "summary:off"]); + act(() => { + applyHostEvent({ kind: "tabRowToolInvoked", value: { id: "terminal" } }); + applyHostEvent({ kind: "tabRowToolInvoked", value: { id: "summary" } }); + }); + expect(called).toHaveLength(2); + called.length = 0; + await render(true, "first"); + expect(sent).toHaveLength(1); + expect(sent[0]!.method).toBe("pane.tabRowTools.set"); + expect(sent[0]!.tools.map(({ id }) => id)).toEqual(["summary", "terminal", "browser"]); + expect(container.querySelectorAll("[data-quick-action]")).toHaveLength(0); + expect(container.querySelector(".acpmux-summary-button")).toBeNull(); + expect(container.querySelector(".acpmux-needs-you-button")).not.toBeNull(); + expect(container.querySelector('button[aria-label="Chat actions"]')).not.toBeNull(); + await render(true, "latest"); + expect(sent).toHaveLength(1); + act(() => { + applyHostEvent({ kind: "tabRowToolInvoked", value: { id: "terminal" } }); + applyHostEvent({ kind: "tabRowToolInvoked", value: { id: "summary" } }); + }); + expect(called).toEqual(["terminal:latest", "summary:latest"]); + act(() => expect(openPicker(translate("summary.open"))).toBe(true)); + expect(called.at(-1)).toBe("summary:latest"); + called.pop(); + // The App uses enabled=false both for the off tunable and a covered/hidden header. + await render(false, "hidden"); + expect(container.querySelectorAll("[data-quick-action]")).toHaveLength(2); + expect(container.querySelector(".acpmux-summary-button")).not.toBeNull(); + expect(sent.at(-1)!.tools).toEqual([]); + const count = sent.length; + act(() => applyHostEvent({ kind: "tabRowToolInvoked", value: { id: "terminal" } })); + expect(called).toHaveLength(2); + await render(false, "still-hidden"); + expect(sent).toHaveLength(count); + await render(true, "shown"); + expect(sent.at(-1)!.tools).toHaveLength(3); + await act(async () => root!.unmount()); + root = undefined; + expect(sent.at(-1)!.tools).toEqual([]); + act(() => applyHostEvent({ kind: "tabRowToolInvoked", value: { id: "terminal" } })); + expect(called).toHaveLength(2); +}); + +test("a failed native set restores web buttons and recovers on changed state", async () => { + const container = mount(); + const sent: NativeRowTool[][] = []; + const calls: string[] = []; + let reject = true; + const callNative = async (_method: string, params: Record) => { + const tools = params.tools as NativeRowTool[]; + sent.push(tools); + if (reject && tools.length) throw new Error("unsupported native host"); + }; + await act(async () => + root!.render( + void calls.push("terminal")} + onBrowser={() => {}} + summary={ + + } + menu={() => []} + nativeRowTools={{ + enabled: true, + callNative, + panel: { id: "summary", label: "Summary", toggled: false, run: () => {} }, + }} + />, + ), + ); + expect(sent[0]).toHaveLength(3); + expect(sent.at(-1)).toEqual([]); + expect(container.querySelectorAll("[data-quick-action]")).toHaveLength(2); + expect(container.querySelector("[data-summary-fixture]")).not.toBeNull(); + act(() => applyHostEvent({ kind: "tabRowToolInvoked", value: { id: "terminal" } })); + expect(calls).toEqual([]); + reject = false; + await act(async () => setHeaderState({ lit: [HEADER_ACTIONS.terminal] })); + expect(container.querySelectorAll("[data-quick-action]")).toHaveLength(0); + await act(async () => setHeaderState({ lit: [] })); + expect(container.querySelectorAll("[data-quick-action]")).toHaveLength(0); + act(() => applyHostEvent({ kind: "tabRowToolInvoked", value: { id: "terminal" } })); + expect(calls).toEqual(["terminal"]); +}); + +test("tab-row native opt-in requires a true flag and an offered positive height", async () => { + const container = mount(); + const sent: NativeRowTool[][] = []; + const callNative = async (_method: string, params: Record) => { + sent.push(params.tools as NativeRowTool[]); + }; + function SaveRow() { + previousTabRow = useTabRow(); + return null; + } + await act(async () => root!.render()); + await act(async () => root!.render(null)); + setTabRow(null); + function Row() { + const row = useTabRow(); + const native = useNativeRowTools({ enabled: row.height > 0 && row.nativeRowTools === true, callNative }, [ + { id: "terminal", icon: "terminal", label: "Terminal", run: () => {} }, + ]); + return ; + } + await act(async () => root!.render()); + for (const value of [ + null, + {}, + { height: 28 }, + { height: 40, nativeRowTools: false }, + { height: 28, nativeRowTools: "true" }, + { height: 0, nativeRowTools: true }, + { height: -1, nativeRowTools: true }, + ]) { + await act(async () => applyHostEvent({ kind: "tabRow", value })); + expect(container.querySelector("[data-native]")?.getAttribute("data-native")).toBe("false"); + } + expect(sent).toEqual([]); + await act(async () => applyHostEvent({ kind: "tabRow", value: { height: 28, nativeRowTools: true } })); + expect(container.querySelector("[data-native]")?.getAttribute("data-native")).toBe("true"); + expect(sent).toHaveLength(1); + await act(async () => applyHostEvent({ kind: "tabRow", value: { height: 40, nativeRowTools: true } })); + expect(sent).toHaveLength(1); + await act(async () => applyHostEvent({ kind: "tabRow", value: { height: 40 } })); + expect(container.querySelector("[data-native]")?.getAttribute("data-native")).toBe("false"); + expect(sent.at(-1)).toEqual([]); +});