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
Original file line number Diff line number Diff line change
Expand Up @@ -19,32 +19,37 @@ public struct AgentPaneHeaderState: Equatable, Sendable {
/// The header offers the session side rail (Debug Settings `agent.sessionRail`, cx-ncc.101), read
/// like `sideSessions`.
public var sessionRail: Bool
/// The header's Browser opens the chat's page and Preview is no button of its own (Debug Settings
/// `agentChat.browserOpensPreview`, on by default), read like `sideSessions`.
public var browserOpensPreview: Bool
/// Rises with every change the App sees: a `pane.tabState` reply that reaches the page after a
/// newer push (two channels) is older, and the page keeps the newer state.
public var version = 0

public init(pinned: Bool = false, sideTabs: Bool = false, lit: [String] = [], floatingChat: Bool = false,
sideSessions: Bool? = nil, sessionRail: Bool? = nil) {
sideSessions: Bool? = nil, sessionRail: Bool? = nil, browserOpensPreview: Bool? = nil) {
self.pinned = pinned
self.sideTabs = sideTabs
self.lit = lit
self.floatingChat = floatingChat
self.sideSessions = sideSessions ?? AgentPaneTunables.sideSessions.value
self.sessionRail = sessionRail ?? AgentPaneTunables.sessionRail.value
self.browserOpensPreview = browserOpensPreview ?? AgentPaneTunables.browserOpensPreview.value
}

/// Whether the header shows the same thing, whatever the versions.
public func shows(_ other: AgentPaneHeaderState) -> Bool {
pinned == other.pinned && sideTabs == other.sideTabs && lit == other.lit && floatingChat == other.floatingChat
&& pullRequest == other.pullRequest && sideSessions == other.sideSessions && sessionRail == other.sessionRail
&& browserOpensPreview == other.browserOpensPreview
}

var reply: [String: Any] { json.foundationObject as? [String: Any] ?? [:] }

/// `pullRequest` is always there, `null` when there is none, so the page drops a chip it kept.
var json: JSONValue {
["pinned": .bool(pinned), "sideTabs": .bool(sideTabs), "lit": .array(lit.map(JSONValue.string)), "floatingChat": .bool(floatingChat),
"sideSessions": .bool(sideSessions), "sessionRail": .bool(sessionRail), "version": JSONValue(version),
"sideSessions": .bool(sideSessions), "sessionRail": .bool(sessionRail), "browserOpensPreview": .bool(browserOpensPreview), "version": JSONValue(version),
"pullRequest": pullRequest?.json ?? .null]
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ public nonisolated struct AgentPaneTunables {
help: "Each click of an agent chat's Terminal button opens a new terminal right of the last one and focuses it, instead of closing the terminal it opened.",
default: false, code: "AgentPaneTunables.terminalButtonAdds")

/// Leo 2026-10-20, "these buttons are kind of weird": one globe instead of Preview beside Browser.
/// On (the default), the chat header's Browser opens the chat's local page beside the chat when its
/// last turns named one, and Preview is not a button of its own. Off: Preview shows as its own
/// button and Browser always opens a plain browser, as before. Pushed with the header state.
public static let browserOpensPreview = Tunable<Bool>.toggle(
"agentChat.browserOpensPreview", .panes, "Browser button opens the chat's page",
help: "An agent chat's Browser button opens the local page the chat named (its preview) beside the chat, and Preview is not a separate button. Off: separate Preview and Browser buttons.",
default: true, code: "AgentPaneTunables.browserOpensPreview")

/// How wide the chat's column draws on a wide pane (cx-ncc.107, a trial before real Settings).
/// The page gets it with the theme (`AgentPaneTheme`, `chatWidth`), again on each change.
public static let chatWidth = Tunable<ChatWidth>.choice(
Expand Down Expand Up @@ -356,6 +365,7 @@ public nonisolated struct AgentPaneTunables {
sideSessions.descriptor,
suggestedTasks.descriptor,
terminalButtonAdds.descriptor,
browserOpensPreview.descriptor,
thoughtFoldThreshold.descriptor,
thoughtHeader.descriptor,
topStripStyle.descriptor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@ public nonisolated enum ChromeTunables {
"chrome.tabs.compact", .tabs, "Compact tabs",
help: "A tighter tab strip at compact density: 26 pt strip, 22 pt tabs, the icon 6 pt in and 4 pt from its title. Off: 28, 24, 8 and 6.",
default: false, code: "ChromeTunables.compactTabs")
// @intent no top bar (Leo 2026-10-14): a chat's header tools in its pane's tab row is opt-in, off by default.
// @intent no top bar (Leo 2026-10-14; on by default 2026-10-20, "I thought we moved that all the way up
// to the top right"): a chat's header tools sit in its pane's tab row, not a second row under it.
public static let tabRowTools = Tunable<Bool>.toggle(
"chrome.tabs.rowTools", .tabs, "Chat tools in the tab row",
help: "A chat's header tools sit at the right end of its pane's tab row instead of a row of their own; the tabs make room. Off: the chat header is its own row under the tabs.",
default: false, code: "ChromeTunables.tabRowTools")
default: true, code: "ChromeTunables.tabRowTools")
// @intent opt-in looks live in Debug Settings, off by default, until promoted (Lawrence via Leo, 2026-10-15).
public static let interfaceSize = Tunable<Bool>.toggle(
"chrome.interfaceSize", .shape, "Interface size",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import CmuxNextDesign
import CmuxNextSettings
import Testing
@testable import CmuxNextAgentPane

/// The chat header's Browser opening the chat's page, behind Debug Settings
/// `agentChat.browserOpensPreview` (on by default, Leo 2026-10-20): the page learns the toggle from
/// the header state, so with it off the header shows Preview beside a plain Browser again.
@Suite(.serialized)
@MainActor
struct AgentPaneBrowserOpensPreviewHeaderTests {
private func with(_ value: Bool?, _ body: () throws -> Void) rethrows {
TunableStore.shared.activate(file: nil)
TunableStore.shared.register(AgentPaneTunables.all)
TunableStore.shared.set(AgentPaneTunables.browserOpensPreview.key, value.map(TunableValue.bool))
defer { TunableStore.shared.set(AgentPaneTunables.browserOpensPreview.key, nil) }
try body()
}

@Test func onByDefaultThePageIsToldBrowserOpensThePage() {
with(nil) {
#expect(AgentPaneTunables.browserOpensPreview.value == true)
#expect(AgentPaneHeaderState().json.objectValue?["browserOpensPreview"] == .bool(true))
}
}

@Test func offThePageIsToldToKeepPreviewSeparate() {
with(false) {
#expect(AgentPaneHeaderState().json.objectValue?["browserOpensPreview"] == .bool(false))
#expect(!AgentPaneHeaderState().shows(AgentPaneHeaderState(browserOpensPreview: true)))
}
}
}
3 changes: 2 additions & 1 deletion webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ import { chatImages, type ChatImage } from "./conversation/chatImages";
import { ImageViewer } from "./conversation/ImageViewer";
import { ImageViewerContext } from "./conversation/imageViewerContext";
import { sessionLink } from "./links";
import { ChatHeaderStatus } from "./header/ChatHeaderStatus";
import { ChatHeaderInlineStatus, ChatHeaderStatus } from "./header/ChatHeaderStatus";
import { ChatHeaderTools, HEADER_ACTIONS, menuGroups, type ChatMenuItem } from "./header/ChatHeaderTools";
import { headerState, setHeaderState, useHeaderState } from "./header/headerState";
import { setTabRow, tabRowStyle, useTabRow, useTabRowTools } from "./header/tabRow";
Expand Down Expand Up @@ -6494,6 +6494,7 @@ function AcpmuxPane() {
<header className="acpmux-header" data-tab-row={inTabRow ? "" : undefined}>
<ChatHeaderStatus status={header.status} detail={header.detail} />
<div className="acpmux-handoff-header-tools" ref={tabRowTools}>
{inTabRow && <ChatHeaderInlineStatus status={header.status} detail={header.detail} />}
{preview && (
<span
className="acpmux-session-coverage"
Expand Down
30 changes: 18 additions & 12 deletions webviews/src/agent-session/acpmux/header/ChatHeaderStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import { Icon } from "../icons/Icon";

/** The slot stays in place; the problem label cannot resize it or move the header tools. */
export function ChatHeaderStatus({ status, detail }: { status: string; detail?: string }) {
return <div className="acpmux-header-status-slot">{status && <StatusLabel status={status} detail={detail} />}</div>;
}

/** The same label at the start of the tools, for a header in the pane's tab row: the slot's part of
* that row is under the App's tabs, so a disconnected chat would show no warning at all. */
export function ChatHeaderInlineStatus({ status, detail }: { status: string; detail?: string }) {
return status ? <StatusLabel status={status} detail={detail} inline /> : null;
}

function StatusLabel({ status, detail, inline }: { status: string; detail?: string; inline?: boolean }) {
return (
<div className="acpmux-header-status-slot">
{status && (
<output
className="acpmux-status"
/* tooltip-allow: the warning's detail, shown nowhere else */ title={detail}
aria-label={detail}
>
<Icon name="status.warning" size={13} />
<span>{status}</span>
</output>
)}
</div>
<output
className={inline ? "acpmux-status acpmux-status--inline" : "acpmux-status"}
/* tooltip-allow: the warning's detail, shown nowhere else */ title={detail}
aria-label={detail}
>
<Icon name="status.warning" size={13} />
<span>{status}</span>
</output>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ const openMenu: Play = async (ctx) => {
await ctx.waitFor(() => ctx.document.querySelector('[role="menu"]'));
};

// The App reports Terminal's, Browser's, Changes' and Preview's panels open and panes beside the chat
// (its headerState push).
// The App reports Terminal's, Browser's (a plain browser and the chat's page, both Browser's to close),
// and Changes' panels open and panes beside the chat (its headerState push).
const panelsOpen: Play = async (ctx) => {
const { setHeaderState } = await import("./headerState");
setHeaderState({ lit: ["splitRight", "splitBrowserRight", "openDiffViewer", "preview"], sideTabs: true });
Expand Down Expand Up @@ -299,7 +299,7 @@ export default componentEntry<HeaderToolsProps>({
props: base,
},
"panels-open": {
note: "Changes, Preview, Terminal and Browser are lit while their panels are open beside the chat (cx-00n1); Hide tabs is in the chat menu.",
note: "Changes, Terminal and Browser are lit while their panels are open beside the chat (cx-00n1); Browser holds the chat's page; Hide tabs is in the chat menu.",
props: {
...base,
sideTabs: true,
Expand All @@ -310,6 +310,10 @@ export default componentEntry<HeaderToolsProps>({
},
play: panelsOpen,
},
"browser-preview": {
note: "The chat's last turn named a local page: Browser opens it beside the chat and its tooltip names it; there is no separate Preview button.",
props: { ...base, previewUrl: "http://localhost:5173/", onPreview: () => undefined },
},
"grouped-controls": {
note: "Quick actions and session metadata are separated without changing the existing button hit targets.",
props: base,
Expand Down
45 changes: 37 additions & 8 deletions webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// The chat header's top right: the quick actions (quickActions.ts: which show, in which order, and
// whether each toggles or opens; by default Changes and Preview, each while it applies, then Terminal
// and Browser, which split the pane in the chat's folder and toggle, as in T3 Chat and ChatGPT); the
// whether each toggles or opens; by default Changes, while it applies, then Terminal and Browser, which
// split the pane in the chat's folder and toggle, as in T3 Chat and ChatGPT; Browser opens the chat's
// local page when there is one, the job of the Preview button a person can still show); the
// summary (Sources) button, whose popover also opens the last turn's changes; and the "..." chat menu,
// which starts with the quick actions not shown as buttons ([+] New tab, which reads Hide tabs in place,
// Files and Split Down; each while it applies). Before them, the chat branch's pull request with its
Expand Down Expand Up @@ -158,8 +159,13 @@ export function ChatHeaderTools({
const splitDownKey = useShortcut(HEADER_ACTIONS.splitDown);
const browserKey = useShortcut(HEADER_ACTIONS.browser);
const changesKey = useShortcut(HEADER_ACTIONS.changes);
const actions = useQuickActions();
const { lit, pullRequest } = useHeaderState();
const { lit, pullRequest, browserOpensPreview } = useHeaderState();
// With the Debug toggle off, the default buttons are the old ones: Preview beside Browser.
const quick = useQuickActions();
const actions =
!browserOpensPreview && JSON.stringify(quick) === JSON.stringify(QUICK_ACTION_DEFAULTS)
? 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);
Expand Down Expand Up @@ -218,7 +224,14 @@ export function ChatHeaderTools({
shortcutAction: HEADER_ACTIONS.changes,
onSelect: () => onChanges("toggle"),
});
if (onPreview && !shownIds.has("preview") && (previewUrl || lit.includes(HEADER_ACTIONS.preview)))
// Browser opens the chat's page itself (Debug `agentChat.browserOpensPreview`), so then Preview
// folds only without a Browser button.
if (
onPreview &&
!shownIds.has("preview") &&
!(shownIds.has("browser") && headerState().browserOpensPreview) &&
(previewUrl || lit.includes(HEADER_ACTIONS.preview))
)
folded.push({
key: "quick-preview",
label: t("header.preview"),
Expand Down Expand Up @@ -250,6 +263,20 @@ 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 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 }) =>
Expand Down Expand Up @@ -351,9 +378,11 @@ export function ChatHeaderTools({
type="button"
className="acpmux-header-tool"
aria-label={t("header.browser")}
aria-pressed={on(HEADER_ACTIONS.browser, mode)}
{...tooltipProps(t("header.browser"), browserKey)}
onClick={() => onBrowser(mode)}
aria-pressed={browserPressed(mode)}
{...(previewInBrowser && previewUrl
? tooltipProps(t("header.previewAddress", { address: previewAddress(previewUrl) }), undefined)
: tooltipProps(t("header.browser"), browserKey))}
onClick={() => browserRun(mode)}
>
<Icon name="browser" size={15} />
</button>
Expand Down
2 changes: 2 additions & 0 deletions webviews/src/agent-session/acpmux/header/header.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions webviews/src/agent-session/acpmux/header/headerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export interface HeaderState {
readonly floatingChat: boolean;
/// The header offers the session side rail (Debug Settings `agent.sessionRail`, cx-ncc.101).
readonly sessionRail: boolean;
/// Browser opens the chat's page and Preview is no button of its own (Debug Settings
/// `agentChat.browserOpensPreview`, on by default; off: separate Preview and Browser).
readonly browserOpensPreview: boolean;
/// The chip beside the tools; the App sends `null` when the chat's folder has none.
readonly pullRequest?: HeaderPullRequest;
/// Rises with each change the App sees: a `pane.tabState` reply that arrives after a newer push is dropped.
Expand All @@ -49,6 +52,7 @@ let current: HeaderState = {
lit: [],
floatingChat: false,
sessionRail: false,
browserOpensPreview: true,
version: 0,
};
const listeners = new Set<() => void>();
Expand All @@ -61,6 +65,7 @@ export function setHeaderState(value: unknown): void {
lit?: unknown;
floatingChat?: unknown;
sessionRail?: unknown;
browserOpensPreview?: unknown;
pullRequest?: unknown;
version?: unknown;
};
Expand All @@ -72,6 +77,8 @@ export function setHeaderState(value: unknown): void {
lit: Array.isArray(raw.lit) ? raw.lit.filter((id): id is string => typeof id === "string") : current.lit,
floatingChat: typeof raw.floatingChat === "boolean" ? raw.floatingChat : current.floatingChat,
sessionRail: typeof raw.sessionRail === "boolean" ? raw.sessionRail : current.sessionRail,
browserOpensPreview:
typeof raw.browserOpensPreview === "boolean" ? raw.browserOpensPreview : current.browserOpensPreview,
pullRequest: "pullRequest" in raw ? pullRequestOf(raw.pullRequest) : current.pullRequest,
version,
};
Expand Down
Loading