Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plans/cmux-next/coordination/react-pages.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
19 changes: 18 additions & 1 deletion webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11152,6 +11152,23 @@ function AcpmuxPane() {
</span>
)}
<ChatHeaderTools
nativeRowTools={{
enabled: inTabRow && tabRow.nativeRowTools === true && !diffFiles && !inspectorOpen,
callNative,
panel: inspector.on
? {
id: "inspector",
label: t("chatInspector.title"),
toggled: inspector.shown === true,
run: inspector.toggle,
}
: {
id: "summary",
label: t("summary.open"),
toggled: summaryCard.shown,
run: summaryCard.toggle,
},
}}
tabTools={!quick}
pullRequestChip={!readOnlySubagent}
// A new chat's picked folder too: New Worktree names its branch before the first prompt.
Expand Down Expand Up @@ -11219,7 +11236,7 @@ function AcpmuxPane() {
}}
inspector={inspector.button}
inbox={<NeedsYouButton items={needsYou.items} mode={needsYou.mode} actions={needsYou.actions} />}
summary={inspector.on ? null : <SummaryButton card={summaryCard} />}
summary={inspector.on ? null : (native) => <SummaryButton card={summaryCard} native={native} />}
menu={chatMenu}
onMenuOpen={readTabState}
expand={continuing && canContinue ? "continue" : undefined}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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<unknown>;
onMergePullRequest?: (pullRequest: { number: number; url: string }) => Promise<{ queued: boolean }>;
Expand Down Expand Up @@ -341,6 +345,13 @@ const withMachine = (machine: ChatMenuItem[]): ChatMenuItem[] => {
return [...rows.slice(0, archive), ...machine, "separator", ...rows.slice(archive)];
};

const inbox = (
<NeedsYouButton
items={[]}
actions={{ answer: async () => undefined, goTo: () => undefined, ask: async () => undefined }}
/>
);

const base: HeaderToolsProps = {
onTerminal: () => undefined,
onSplitDown: () => undefined,
Expand Down Expand Up @@ -405,6 +416,27 @@ export default componentEntry<HeaderToolsProps>({
},
},
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: {
Expand Down
211 changes: 51 additions & 160 deletions webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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"
Expand All @@ -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 };

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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);
Expand All @@ -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<QuickActionId>(undefined);
const [menuTarget, setMenuTarget] = useState<QuickActionId>();
Expand Down Expand Up @@ -278,142 +254,57 @@ 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<Record<QuickActionId, string | undefined>> = {
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.
<button
key={id}
data-quick-action={id}
type="button"
className="acpmux-header-tool"
aria-label={t(sideTabs ? "header.hideTabs" : "header.newTab")}
aria-pressed={sideTabs}
title={t(sideTabs ? "header.hideTabs" : "header.newTab")}
onClick={onSideTabs}
>
<HeaderIcon name={sideTabs ? "tunable.sidebar" : "action.add"} size={HEADER_GLYPH} />
</button>
) : 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)) ? (
<button
key={id}
data-quick-action={id}
type="button"
className="acpmux-header-tool"
aria-label={t("header.changes")}
aria-pressed={on(HEADER_ACTIONS.changes, mode)}
{...tooltipProps(t("header.changes"), changesKey)}
onClick={() => onChanges(mode)}
>
<HeaderIcon name="diff.file" size={HEADER_GLYPH} />
</button>
) : null
) : id === "files" ? (
onFiles ? (
<button
key={id}
data-quick-action={id}
type="button"
className="acpmux-header-tool"
aria-label={t("header.files")}
aria-pressed={on(HEADER_ACTIONS.files, mode)}
title={t("header.files")}
onClick={() => onFiles(mode)}
>
<HeaderIcon name="folder" size={HEADER_GLYPH} />
</button>
) : 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)) ? (
<button
key={id}
data-quick-action={id}
type="button"
className="acpmux-header-tool"
aria-label={t("header.preview")}
aria-pressed={on(HEADER_ACTIONS.preview, mode)}
title={
previewUrl ? t("header.previewAddress", { address: previewAddress(previewUrl) }) : t("header.preview")
}
onClick={() => onPreview(previewUrl, mode)}
>
<HeaderIcon name="media.play" size={HEADER_GLYPH} />
</button>
) : null
) : id === "splitDown" ? (
onSplitDown ? (
<button
key={id}
data-quick-action={id}
type="button"
className="acpmux-header-tool"
aria-label={t("header.splitDown")}
aria-pressed={on(HEADER_ACTIONS.splitDown, mode)}
{...tooltipProps(t("header.splitDown"), splitDownKey)}
onClick={() => onSplitDown(mode)}
>
<HeaderIcon name="pane.split.down" size={HEADER_GLYPH} />
</button>
) : null
) : id === "terminal" ? (
<button
key={id}
data-quick-action={id}
type="button"
className="acpmux-header-tool"
aria-label={t("header.terminal")}
aria-pressed={on(HEADER_ACTIONS.terminal, mode)}
{...tooltipProps(t("header.terminal"), terminalKey)}
onClick={() => onTerminal(mode)}
>
<HeaderIcon name="terminal" size={HEADER_GLYPH} />
</button>
) : (
<button
key={id}
data-quick-action={id}
type="button"
className="acpmux-header-tool"
aria-label={t("header.browser")}
aria-pressed={browserPressed(mode)}
{...(previewInBrowser && previewUrl
? tooltipProps(t("header.previewAddress", { address: previewAddress(previewUrl) }), undefined)
: tooltipProps(t("header.browser"), browserKey))}
onClick={() => browserRun(mode)}
>
<HeaderIcon name="browser" size={HEADER_GLYPH} />
</button>
),
)}
</>
);
const buttons =
!native &&
tabTools &&
actions.length > 0 &&
tools.map((tool) => (
<button
key={tool.id}
data-quick-action={tool.id}
type="button"
className="acpmux-header-tool"
aria-label={tool.label}
aria-pressed={tool.pressed}
{...tooltipProps(tool.title ?? tool.label, tool.title ? undefined : shortcuts[tool.id])}
onClick={tool.run}
>
<HeaderIcon name={tool.icon} size={HEADER_GLYPH} />
</button>
));
return (
<div className="acpmux-header-tools" onPointerEnter={onPointerEnter}>
{worktree && <WorktreePill worktree={worktree} onApply={onApplyWorktree} onRemove={onRemoveWorktree} />}
{pullRequest && pullRequestChip !== false && (
<PullRequestChip pullRequest={pullRequest} onMerge={onMergePullRequest} />
)}
{/* 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 ? (
<div className="acpmux-header-tool-group acpmux-header-tool-group-actions">
Expand Down
Loading
Loading