{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([]);
+});