diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHeaderState.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHeaderState.swift index 2475c126e7be..f4051ce87c56 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHeaderState.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHeaderState.swift @@ -19,24 +19,29 @@ 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] ?? [:] } @@ -44,7 +49,7 @@ public struct AgentPaneHeaderState: Equatable, Sendable { /// `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] } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables.swift index 905013797244..27a5afbb39f3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables.swift @@ -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.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.choice( @@ -356,6 +365,7 @@ public nonisolated struct AgentPaneTunables { sideSessions.descriptor, suggestedTasks.descriptor, terminalButtonAdds.descriptor, + browserOpensPreview.descriptor, thoughtFoldThreshold.descriptor, thoughtHeader.descriptor, topStripStyle.descriptor, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift index db9037f2fcb3..5246cd3dbad8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift @@ -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.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.toggle( "chrome.interfaceSize", .shape, "Interface size", diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBrowserOpensPreviewHeaderTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBrowserOpensPreviewHeaderTests.swift new file mode 100644 index 000000000000..d130f8b28f53 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneBrowserOpensPreviewHeaderTests.swift @@ -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))) + } + } +} diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 71279a324a99..62eef613c5f8 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -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"; @@ -6494,6 +6494,7 @@ function AcpmuxPane() {
+ {inTabRow && } {preview && ( {status && }
; +} + +/** 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 ? : null; +} + +function StatusLabel({ status, detail, inline }: { status: string; detail?: string; inline?: boolean }) { return ( -
- {status && ( - - - {status} - - )} -
+ + + {status} + ); } diff --git a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.gallery.tsx b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.gallery.tsx index d7ca28fcfc68..38306c572562 100644 --- a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.gallery.tsx +++ b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.gallery.tsx @@ -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 }); @@ -299,7 +299,7 @@ export default componentEntry({ 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, @@ -310,6 +310,10 @@ export default componentEntry({ }, 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, diff --git a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx index 9c215d783b44..0d134176e54d 100644 --- a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx +++ b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx @@ -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 @@ -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); @@ -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"), @@ -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 }) => @@ -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)} > diff --git a/webviews/src/agent-session/acpmux/header/header.css b/webviews/src/agent-session/acpmux/header/header.css index 7b314e1433a9..55a427482c04 100644 --- a/webviews/src/agent-session/acpmux/header/header.css +++ b/webviews/src/agent-session/acpmux/header/header.css @@ -106,5 +106,7 @@ shows only its tools, left of the pane's own "…"; the transcript fades out under the row. */ .acpmux-header[data-tab-row]{position:relative;z-index:var(--layer-sticky);flex:0 0 var(--acpmux-tab-row-height);height:var(--acpmux-tab-row-height);padding:0 var(--acpmux-tab-row-inset) 0 0;background:transparent;pointer-events:none} .acpmux-header[data-tab-row] .acpmux-header-status-slot{visibility:hidden} +/* In the tab row the warning leads the tools (ChatHeaderInlineStatus): the slot is under the tabs. */ +.acpmux-status.acpmux-status--inline{position:static;inset:auto;flex:0 1 auto;max-width:160px;height:26px;padding:0 4px} .acpmux-header[data-tab-row] .acpmux-handoff-header-tools{pointer-events:auto} .acpmux-header[data-tab-row]::after{content:"";position:absolute;inset:100% 0 auto;height:12px;background:linear-gradient(var(--agent-page-bg),transparent);pointer-events:none} diff --git a/webviews/src/agent-session/acpmux/header/headerState.ts b/webviews/src/agent-session/acpmux/header/headerState.ts index 369bc7ea1d7b..94f1d89e1938 100644 --- a/webviews/src/agent-session/acpmux/header/headerState.ts +++ b/webviews/src/agent-session/acpmux/header/headerState.ts @@ -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. @@ -49,6 +52,7 @@ let current: HeaderState = { lit: [], floatingChat: false, sessionRail: false, + browserOpensPreview: true, version: 0, }; const listeners = new Set<() => void>(); @@ -61,6 +65,7 @@ export function setHeaderState(value: unknown): void { lit?: unknown; floatingChat?: unknown; sessionRail?: unknown; + browserOpensPreview?: unknown; pullRequest?: unknown; version?: unknown; }; @@ -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, }; diff --git a/webviews/src/agent-session/acpmux/header/quickActions.ts b/webviews/src/agent-session/acpmux/header/quickActions.ts index 5986d8261a5b..9c5c7a9b8b80 100644 --- a/webviews/src/agent-session/acpmux/header/quickActions.ts +++ b/webviews/src/agent-session/acpmux/header/quickActions.ts @@ -4,9 +4,10 @@ import { useSyncExternalStore } from "react"; /// in which order, and whether each one toggles its panel or opens another copy. This is the one model /// the header reads (ChatHeaderTools). The default copies T3 Chat and ChatGPT: every button toggles, /// so a second click closes what the first one opened. By default only the most-used show (Changes, -/// Preview, Terminal, Browser; Leo 2026-10-12: about four buttons with "..."): [+] New tab, Files and -/// Split Down fold into the top of the "..." chat menu, each while it applies, until the person shows -/// them here. +/// Terminal, Browser; Leo 2026-10-12: about four buttons with "..."), and Browser opens the chat's +/// local page when it has one (Leo 2026-10-20: one globe, not a play button beside it): Preview, +/// [+] New tab, Files and Split Down stay off until the person shows them here; New tab, Files and +/// Split Down fold into the top of the "..." chat menu meanwhile, each while it applies. /// /// The user customizes it in `agent-pane/layout.json` next to `cmux.json` (the file the App watches and /// pushes through `applyCustomization`): @@ -43,7 +44,8 @@ export interface QuickAction { const ids: readonly QuickActionId[] = ["changes", "preview", "terminal", "splitDown", "browser", "files", "newTab"]; /// Not shown until a person adds them (in the chat menu meanwhile). -const OFF_BY_DEFAULT: ReadonlySet = new Set(["newTab", "files", "splitDown"]); +/// Preview too (Leo 2026-10-20, "these buttons are kind of weird"): Browser opens the chat's page when it has one. +const OFF_BY_DEFAULT: ReadonlySet = new Set(["preview", "newTab", "files", "splitDown"]); const isId = (value: unknown): value is QuickActionId => ids.includes(value as QuickActionId); const isMode = (value: unknown): value is QuickActionMode => value === "toggle" || value === "open"; diff --git a/webviews/test/chat-header-browser-preview.test.tsx b/webviews/test/chat-header-browser-preview.test.tsx new file mode 100644 index 000000000000..d2f061efc9e3 --- /dev/null +++ b/webviews/test/chat-header-browser-preview.test.tsx @@ -0,0 +1,144 @@ +// The chat header's Browser opens what the chat is about (Leo 2026-10-20: "something different was +// opening here"): with a local page from the chat's last turns it opens that page beside the chat, +// names it in its tooltip and lights while it is open, and a second click closes it; without one it +// opens a plain browser split, as before. Preview is no longer a button of its own by default; a +// person who shows it gets the separate Preview and a plain Browser back. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { JSDOM } from "jsdom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; + +const scope = globalThis as Record; +const keys = ["window", "document", "navigator", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const; +const saved = keys.map((key) => [key, key in scope, scope[key]] as const); +let root: Root | undefined; +let host: HTMLElement; + +beforeAll(() => { + const { window } = new JSDOM("
"); + Object.assign(scope, { + window, + document: window.document, + navigator: window.navigator, + HTMLElement: window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + }); + host = window.document.getElementById("root")!; +}); + +afterAll(() => { + act(() => root?.unmount()); + for (const [key, had, value] of saved) { + if (had) scope[key] = value; + else delete scope[key]; + } +}); + +test("Browser opens the chat's local page beside it, lights while it is open, and closes it again", async () => { + const { createRoot } = await import("react-dom/client"); + const { ChatHeaderTools, HEADER_ACTIONS } = await import("../src/agent-session/acpmux/header/ChatHeaderTools"); + const { applyHostEvent } = await import("../src/agent-session/acpmux/pageHost"); + const { configureQuickActions } = await import("../src/agent-session/acpmux/header/quickActions"); + // The default buttons, and nothing open yet (other files in this run share the page's modules). + configureQuickActions({}); + applyHostEvent({ kind: "headerState", value: { lit: [] } }); + const runs: string[] = []; + root = createRoot(host); + const render = (previewUrl: string | undefined) => + act(() => + root!.render( + {}} + onBrowser={(mode) => runs.push(`browser ${mode}`)} + previewUrl={previewUrl} + onPreview={(url, mode) => runs.push(`preview ${mode} ${url}`)} + summary={null} + menu={() => []} + />, + ), + ); + const button = (label: string) => + host.querySelector(`.acpmux-header-tools button[aria-label="${label}"]`); + const browser = () => button("Browser"); + + // No page: a plain browser split, and no Preview button beside it. + render(undefined); + expect(button("Preview")).toBeNull(); + expect(browser()?.getAttribute("title")).toBe("Browser"); + act(() => browser()!.click()); + expect(runs).toEqual(["browser toggle"]); + + // A page: the same button opens it and says so. + runs.length = 0; + render("http://localhost:5173/"); + expect(button("Preview")).toBeNull(); + expect(browser()?.getAttribute("title")).toBe("Preview localhost:5173"); + expect(browser()?.getAttribute("aria-pressed")).toBe("false"); + act(() => browser()!.click()); + expect(runs).toEqual(["preview toggle http://localhost:5173/"]); + + // Open: lit, and the next click closes the preview it opened. + act(() => applyHostEvent({ kind: "headerState", value: { lit: [HEADER_ACTIONS.preview] } })); + expect(browser()?.getAttribute("aria-pressed")).toBe("true"); + act(() => browser()!.click()); + // The page goes along: a preview closed since the last state push opens again rather than nothing. + expect(runs).toEqual(["preview toggle http://localhost:5173/", "preview toggle http://localhost:5173/"]); + + // A plain browser split already open: the click closes that one, not opens the page. + runs.length = 0; + act(() => applyHostEvent({ kind: "headerState", value: { lit: [HEADER_ACTIONS.browser] } })); + expect(browser()?.getAttribute("aria-pressed")).toBe("true"); + act(() => browser()!.click()); + expect(runs).toEqual(["browser toggle"]); + + // Preview shown as its own button (layout.json): Browser is a plain browser again. + runs.length = 0; + act(() => applyHostEvent({ kind: "headerState", value: { lit: [] } })); + act(() => configureQuickActions({ quickActions: ["preview", "terminal", "browser"] })); + render("http://localhost:5173/"); + expect(button("Preview")).not.toBeNull(); + expect(browser()?.getAttribute("title")).toBe("Browser"); + act(() => browser()!.click()); + expect(runs).toEqual(["browser toggle"]); + act(() => root?.unmount()); + root = undefined; + configureQuickActions({}); +}); + +test("with Debug Settings agentChat.browserOpensPreview off, the default header is Preview beside a plain Browser", async () => { + const { createRoot } = await import("react-dom/client"); + const { ChatHeaderTools } = await import("../src/agent-session/acpmux/header/ChatHeaderTools"); + const { applyHostEvent } = await import("../src/agent-session/acpmux/pageHost"); + const { configureQuickActions } = await import("../src/agent-session/acpmux/header/quickActions"); + configureQuickActions({}); + applyHostEvent({ kind: "headerState", value: { lit: [], browserOpensPreview: false } }); + const runs: string[] = []; + root = createRoot(host); + act(() => + root!.render( + {}} + onBrowser={(mode) => runs.push(`browser ${mode}`)} + previewUrl="http://localhost:5173/" + onPreview={(url, mode) => runs.push(`preview ${mode} ${url}`)} + summary={null} + menu={() => []} + />, + ), + ); + const shown = [...host.querySelectorAll("[data-quick-action]")].map((button) => + button.getAttribute("data-quick-action"), + ); + expect(shown).toEqual(["preview", "terminal", "browser"]); + const browser = host.querySelector('.acpmux-header-tools button[aria-label="Browser"]')!; + expect(browser.getAttribute("title")).toBe("Browser"); + act(() => browser.click()); + expect(runs).toEqual(["browser toggle"]); + // Back on: the page follows the next push. + act(() => applyHostEvent({ kind: "headerState", value: { browserOpensPreview: true } })); + expect( + [...host.querySelectorAll("[data-quick-action]")].map((button) => button.getAttribute("data-quick-action")), + ).toEqual(["terminal", "browser"]); + act(() => root?.unmount()); + root = undefined; +}); diff --git a/webviews/test/chat-header-customize.test.tsx b/webviews/test/chat-header-customize.test.tsx index ef794fc17b4a..4f6847db299f 100644 --- a/webviews/test/chat-header-customize.test.tsx +++ b/webviews/test/chat-header-customize.test.tsx @@ -101,6 +101,6 @@ test("the right-click menu moves, switches, hides and shows the quick actions", rightClick("Browser"); pick("Reset Buttons"); // The default keeps the most-used four; New tab, Files and Split Down are in the chat menu. - expect(lists.at(-1)).toBe("changes:toggle preview:toggle terminal:toggle browser:toggle"); + expect(lists.at(-1)).toBe("changes:toggle terminal:toggle browser:toggle"); act(() => configureQuickActions({})); }); diff --git a/webviews/test/chat-header-fold.test.tsx b/webviews/test/chat-header-fold.test.tsx index 03911982096c..27be7bc35bae 100644 --- a/webviews/test/chat-header-fold.test.tsx +++ b/webviews/test/chat-header-fold.test.tsx @@ -1,6 +1,6 @@ // The chat header keeps the most-used tools visible (Leo 2026-10-12: about four buttons, not -// everything in menus): Changes (while the chat has changes), Preview (while there is a page), Terminal -// and Browser, then Sources and "...". [+] New tab, Files and Split Down fold into the top of "...", and +// everything in menus): Changes (while the chat has changes), Terminal and Browser (which opens the +// chat's page, Leo 2026-10-20), then Sources and "...". [+] New tab, Files and Split Down fold into the top of "...", and // run with the toggle mode as their buttons did. A quick action the person shows in the header // (layout.json) leaves "..." again. import { afterAll, beforeAll, expect, test } from "bun:test"; @@ -57,7 +57,7 @@ const shown = () => button.getAttribute("data-quick-action"), ); -test("the header shows Changes, Preview, Terminal and Browser; New tab, Files and Split Down fold into the chat menu", async () => { +test("the header shows Changes, Terminal and Browser; New tab, Files and Split Down fold into the chat menu", async () => { const { createRoot } = await import("react-dom/client"); const { ChatHeaderTools } = await import("../src/agent-session/acpmux/header/ChatHeaderTools"); const { configureQuickActions } = await import("../src/agent-session/acpmux/header/quickActions"); @@ -87,7 +87,7 @@ test("the header shows Changes, Preview, Terminal and Browser; New tab, Files an configureQuickActions({}); render(); - expect(shown()).toEqual(["changes", "preview", "terminal", "browser"]); + expect(shown()).toEqual(["changes", "terminal", "browser"]); await act(async () => void openPicker("Chat actions")); expect(labels()).toEqual(["New tab", "Files", "Split Down", "Rename"]); const rows = () => [...doc.querySelectorAll(".acpmux-chat-menu-popover .acpmux-chat-menu-item")]; @@ -95,9 +95,9 @@ test("the header shows Changes, Preview, Terminal and Browser; New tab, Files an expect(runs).toEqual(["splitDown:toggle"]); // Shown in the header again: its button is back and "..." no longer lists it. - act(() => configureQuickActions({ quickActions: ["changes", "preview", "terminal", "browser", "splitDown"] })); + act(() => configureQuickActions({ quickActions: ["changes", "terminal", "browser", "splitDown"] })); render(); - expect(shown()).toEqual(["changes", "preview", "terminal", "browser", "splitDown"]); + expect(shown()).toEqual(["changes", "terminal", "browser", "splitDown"]); await act(async () => void openPicker("Chat actions")); expect(labels()).toEqual(["New tab", "Files", "Rename"]); act(() => root?.unmount()); @@ -148,9 +148,15 @@ test("a hidden Changes or Preview folds into the chat menu while it applies; the expect(await open()).toEqual(["Changes", "Hide tabs", "Rename"]); await act(async () => (doc.querySelector(".acpmux-chat-menu-popover .acpmux-chat-menu-item") as HTMLElement).click()); expect(runs).toEqual(["changes:toggle"]); - // The diff and the preview opened since this render: the refresh as "..." opens lists them. + // The diff and the preview opened since this render: the refresh as "..." opens lists the diff; + // the preview is Browser's, lit on its button. + render({ refresh: [HEADER_ACTIONS.changes, HEADER_ACTIONS.preview] }); + expect(await open()).toEqual(["Changes", "New tab", "Rename"]); + // Without a Browser button, the open preview folds into "..." as before. + act(() => configureQuickActions({ quickActions: ["terminal"] })); render({ refresh: [HEADER_ACTIONS.changes, HEADER_ACTIONS.preview] }); expect(await open()).toEqual(["Changes", "Preview", "New tab", "Rename"]); + act(() => configureQuickActions({ quickActions: ["terminal", "browser"] })); // Quick Chat has no tab tools: nothing folds. act(() => setHeaderState({ lit: [] })); render({ hasChanges: true, tabTools: false }); diff --git a/webviews/test/chat-header-inline-status.test.tsx b/webviews/test/chat-header-inline-status.test.tsx new file mode 100644 index 000000000000..bf3148905db0 --- /dev/null +++ b/webviews/test/chat-header-inline-status.test.tsx @@ -0,0 +1,49 @@ +// A chat header in the pane's tab row (chrome.tabs.rowTools, on by default since 2026-10-20) hides its +// status slot, which sits under the App's tabs; the connection warning then leads the tools instead, +// so a disconnected or failed chat still says so, with the reason in its tooltip. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { JSDOM } from "jsdom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; + +const scope = globalThis as Record; +const keys = ["window", "document", "navigator", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const; +const saved = keys.map((key) => [key, key in scope, scope[key]] as const); +let root: Root | undefined; +let host: HTMLElement; + +beforeAll(() => { + const { window } = new JSDOM("
"); + Object.assign(scope, { + window, + document: window.document, + navigator: window.navigator, + HTMLElement: window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + }); + host = window.document.getElementById("root")!; +}); + +afterAll(() => { + act(() => root?.unmount()); + for (const [key, had, value] of saved) { + if (had) scope[key] = value; + else delete scope[key]; + } +}); + +test("the inline status shows the warning and its reason, and nothing while connected", async () => { + const { createRoot } = await import("react-dom/client"); + const { ChatHeaderInlineStatus } = await import("../src/agent-session/acpmux/header/ChatHeaderStatus"); + root = createRoot(host); + act(() => root!.render()); + expect(host.querySelector(".acpmux-status")).toBeNull(); + act(() => root!.render()); + const label = host.querySelector(".acpmux-status--inline"); + expect(label?.textContent).toBe("Disconnected"); + expect(label?.getAttribute("title")).toBe("acpmux stopped answering"); + // Not inside the hidden slot: it renders wherever the tools put it. + expect(host.querySelector(".acpmux-header-status-slot")).toBeNull(); + act(() => root?.unmount()); + root = undefined; +});