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 @@ -109,7 +109,4 @@ extension PaneTabRowSharing where Self: AgentPaneView {
public var tabRowToolsNeed: CGFloat? { model.tabRow.sharedNeed(zoom: webView.pageZoom) }
public var onTabRowToolsChange: (() -> Void)? { get { model.tabRow.onChange } set { model.tabRow.onChange = newValue } }
public func setTabRow(height: CGFloat, trailingInset: CGFloat) { AgentPaneTabRow.offer(points: height, trailingInset: trailingInset, to: self) }
/// The chat's page paints its surface's background (an `appearance.surfaces.agentPane` color)
/// under the header; the pane paints the same behind its tabs, so the row is one fill.
public var tabRowSurface: SurfaceKind? { surfaceKind }
}
16 changes: 4 additions & 12 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@ final class PaneContentView: NSView, PaneContentChrome {
let backdropState = PaneBackdropState()
private var paneBackgroundsObservation: Task<Void, Never>?
let contentHost = NSView()
/// The surface of the content sharing the tab row now (``PaneTabRowSharing/tabRowSurface``), whose
/// background the strip paints behind its tabs (``paintStrip()``); nil while nothing shares it.
private(set) var rowSurface: SurfaceKind?
private(set) weak var content: NSView?
/// Explicitly observed ownership: first-paint holds can change this without a tab-store update.
let nativeToolOwner = PaneNativeToolOwner()
Expand Down Expand Up @@ -177,8 +174,6 @@ final class PaneContentView: NSView, PaneContentChrome {
toolbar?.view.isHidden = shown <= 0
let tools = toolbarWidth(shown: shown), room = bounds.width - tools, row = onTop && !isBandActive && !hidesStrip && hostsContent && shown > 0 ? content as? PaneTabRowSharing : nil
let rowContent = row?.fitsTabRow(room: room) == true ? row : nil, rowTools = rowContent?.tabRowWidth(room: room) ?? 0 // a chat's tools in the row when they fit (PaneTabRowSharing)
let surface = rowTools > 0 ? rowContent?.tabRowSurface : nil
if surface != rowSurface { rowSurface = surface; paintStrip() }
if !isBandActive {
stripView.frame = NSRect(x: 0, y: onTop ? 0 : bounds.height - shown, width: bounds.width - tools - rowTools, height: shown)
toolbar?.view.frame = NSRect(x: bounds.width - tools, y: stripView.frame.minY, width: tools, height: shown)
Expand Down Expand Up @@ -469,15 +464,12 @@ final class PaneContentView: NSView, PaneContentChrome {
paintPaneBackdrop()
}

/// The strip: clear, or the user's tab bar background (R55). While a chat shares the row, the
/// background its page paints under the header (``PaneTabRowSharing/tabRowSurface``), so the row
/// behind the tabs and the chat's tools is one fill with no band of its own.
/// The strip: clear, or the user's tab bar background (R55). A chat sharing the row never gives it
/// a fill: the row, its tabs and the chat's tools sit on the window's own glass (Leo, 2026-11-03,
/// on the chat's color behind the header buttons: "it's so obvious to just remove the thing").
func paintStrip() {
stripView.wantsLayer = true
let surface = rowSurface
stripView.layer?.backgroundColor = stripView.performWithTheme {
(Palette.surfaceOverride(.tabBar) ?? surface.flatMap { Palette.surfaceOverride($0) })?.cgColor
}
stripView.layer?.backgroundColor = stripView.performWithTheme { Palette.surfaceOverride(.tabBar)?.cgColor }
}
}
// A terminal or page edge inside the titlebar band never moves the window;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,9 @@ public protocol PaneTabRowSharing: AnyObject {
/// The row the view may draw its tools in: the top `height` points (0: it does not share the
/// row now), less `trailingInset` points at the trailing end, where the pane's own toolbar is.
func setTabRow(height: CGFloat, trailingInset: CGFloat)
/// The surface whose background (`appearance.surfaces`) the view paints under its header, so the
/// pane paints the same fill behind its tabs while the view shares the row: the row has no fill
/// of its own. Nil: the strip keeps its own (clear, or the user's tab bar background).
var tabRowSurface: SurfaceKind? { get }
}

extension PaneTabRowSharing {
public var tabRowSurface: SurfaceKind? { nil }
/// Points the strip gives up beside the view's tools in a row `room` wide (at most half of it).
public func tabRowWidth(room: CGFloat) -> CGFloat { min(max(0, tabRowToolsWidth ?? 0), max(0, room / 2)) }

Expand Down Expand Up @@ -61,7 +56,6 @@ extension PaneTabRowForwarding {
set { tabRowSharer?.onTabRowToolsChange = newValue }
}
public func setTabRow(height: CGFloat, trailingInset: CGFloat) { tabRowSharer?.setTabRow(height: height, trailingInset: trailingInset) }
public var tabRowSurface: SurfaceKind? { tabRowSharer?.tabRowSurface }
}

extension NSView {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import Testing
/// A chat that draws `need` points of tools in the row whenever the pane offers it, as the page does.
final class RowChat: NSView, PaneTabRowSharing {
let need: CGFloat
var tabRowSurface: SurfaceKind? = .agentPane
private(set) var shared: CGFloat = 0
var onTabRowToolsChange: (() -> Void)?
init(need: CGFloat) {
Expand Down Expand Up @@ -65,12 +64,13 @@ import Testing
#expect(chatUnderRow == (CGFloat(width) - pane.toolbarWidth(shown: strip.height) >= chat.need * 2))
}

/// Leo (2026-11-01), on a chat with its own background color (`appearance.surfaces.agentPane`):
/// "why is the background STILL not removed". The page painted the chat's color under its tools,
/// but the row behind the tabs kept the pane's theme background, a darker band across the top.
/// While the chat shares the row, the strip paints the chat's background; otherwise its own.
/// Leo (2026-11-03), on the chat's own color (`appearance.surfaces.agentPane`) drawn as a rectangle
/// behind the header buttons in the pane's tab row: "it's so obvious to just remove the thing". The
/// row has no backing: while the chat shares it, the strip behind the tabs stays clear (only the
/// user's own tab bar color fills it), as the page leaves the row clear behind the tools
/// (webviews header.css, `html:has(.acpmux-shell[data-tab-row])`). #32039 painted the chat's color here.
@Test(arguments: [480, 900])
func theRowBehindTheTabsTakesTheChatsBackground(width: Int) throws {
func theTabRowHasNoBackingWhileTheChatSharesIt(width: Int) throws {
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: CGFloat(width), height: 500), styleMask: [.titled],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
Expand All @@ -87,19 +87,13 @@ import Testing
pane.needsLayout = true
pane.layoutSubtreeIfNeeded()
}
let shares = pane.contentHost.frame.minY < pane.stripView.frame.maxY
let fill = pane.stripView.layer?.backgroundColor.flatMap { NSColor(cgColor: $0)?.usingColorSpace(.sRGB) }
if shares {
let shown = try #require(fill, "the row behind the tabs has no fill while the chat paints its own")
#expect(abs(shown.redComponent - chatColor.red) < 0.01 && abs(shown.blueComponent - chatColor.blue) < 0.01,
"the strip paints \(shown), not the chat's \(chatColor)")
} else {
#expect(fill == nil, "a chat in its own header row leaves the strip clear")
}
// Nothing sharing the row: the strip is clear again.
chat.tabRowSurface = nil
pane.needsLayout = true
pane.layoutSubtreeIfNeeded()
#expect(pane.stripView.layer?.backgroundColor == nil)
#expect(pane.stripView.layer?.backgroundColor == nil, "the tab row paints the chat's color behind the tabs")
// The user's own tab bar color still fills the strip.
let barColor = ThemeRGB(hex: 0x113355)
pane.paneScope.setSurfaceBackgrounds(SurfaceBackgrounds(overrides: [.agentPane: SurfaceBackground(color: chatColor, opacity: 1),
.tabBar: SurfaceBackground(color: barColor, opacity: 1)]))
pane.themeDidChange()
let fill = try #require(pane.stripView.layer?.backgroundColor.flatMap { NSColor(cgColor: $0)?.usingColorSpace(.sRGB) })
#expect(abs(fill.blueComponent - barColor.blue) < 0.01, "the strip paints \(fill), not the tab bar's \(barColor)")
}
}
11 changes: 8 additions & 3 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.

103 changes: 61 additions & 42 deletions webviews/test/chat-header-tab-row-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,48 +171,67 @@ await requireBrowserLane("chat-header-tab-row-surface.test.ts", async () => {
}
}

// The tools in the tab row sit on the row's own surface, as the tabs do: the shell paints no band of the page's
// color behind them, only below the row (Leo, 2026-11-02: "FOR THE FUCKING STUPID BACKGROUND ON THE FUCKING TOP
// RIGHT ICONS TO BE REMOVED").
test("the tab row shell paints nothing behind the tools", async () => {
const shellCss = [
read("styles.css"),
read("agent-session/shared/styles.css"),
read("agent-session/acpmux/styles.css"),
read("agent-session/acpmux/header/header.css"),
].join("\n");
const page = await browser.newPage({ viewport: { width: 320, height: 120 } });
try {
// The native row under the page is green; the page's own color is red.
await page.setContent(`<!doctype html><html style="background:rgb(0,255,0)"><head><style>
${shellCss}
:root { --agent-page-bg: rgb(255, 0, 0); --agent-muted: rgb(150, 160, 170); }
html:has(.acpmux-shell[data-tab-row]) { background: rgb(0, 255, 0) !important; }
body { margin: 0; height: 120px; }
</style></head><body>
<section class="acpmux-shell" data-tab-row style="--acpmux-tab-row-height:40px;height:120px">
<div class="acpmux-stage" style="flex:1;display:flex;flex-direction:column">
<header class="acpmux-header" data-tab-row><div class="acpmux-handoff-header-tools">
<div class="acpmux-header-tools"><button class="acpmux-header-tool" aria-label="Panel"></button></div>
</div></header>
</div>
</section>
</body></html>`);
const shot = await page.screenshot({ type: "png" });
const { PNG } = await import("pngjs");
const png = PNG.sync.read(shot);
const at = (x: number, y: number) => {
const i = (png.width * y + x) * 4;
return [png.data[i], png.data[i + 1], png.data[i + 2]];
};
// Behind the tools (inside the 40px row): the row's green. Below the row: the page's red.
expect(at(300, 10)).toEqual([0, 255, 0]);
expect(at(10, 20)).toEqual([0, 255, 0]);
expect(at(10, 80)).toEqual([255, 0, 0]);
} finally {
await page.close();
}
});
// The tools in the tab row sit on the row's own surface, as the tabs do: nothing on the page paints behind them,
// only below the row (Leo, 2026-11-02: "FOR THE FUCKING STUPID BACKGROUND ON THE FUCKING TOP RIGHT ICONS TO BE
// REMOVED"; 2026-11-03: "it's so obvious to just remove the thing"). The page here is the agent page as it ships:
// its own sheets only (never src/styles.css, which the pane's bundle does not load) under WebTheme's document rule,
// `html{background:var(--cmux-surface-background) !important}`, with a chat color (appearance.surfaces.agentPane)
// as that background. The toggled tool keeps its plate.
for (const surface of ["rgb(255, 0, 0)", "rgba(255, 0, 0, 0.6)"]) {
test(`the tab row paints nothing behind the tools, page background ${surface}`, async () => {
const shellCss = [
read("agent-session/shared/styles.css"),
read("agent-session/acpmux/styles.css"),
read("agent-session/acpmux/header/header.css"),
].join("\n");
const page = await browser.newPage({ viewport: { width: 320, height: 120 } });
try {
// The page's own color is red; its canvas is transparent (omitBackground), as WebKit's and see-through
// Chromium's are, so a pixel the page leaves alone reads alpha 0.
await page.setContent(`<!doctype html><html><head>
<style id="cmux-theme">html{background:var(--cmux-surface-background) !important}body{background:transparent !important}</style>
<style>
${shellCss}
:root { --cmux-surface-background: ${surface}; --agent-page-bg: transparent; --agent-muted: rgb(150, 160, 170);
--agent-text: rgb(240, 240, 240); }
body { margin: 0; height: 120px; }
</style></head><body>
<section class="acpmux-shell" data-tab-row style="--acpmux-tab-row-height:40px;height:120px">
<div class="acpmux-stage" style="flex:1;display:flex;flex-direction:column">
<header class="acpmux-header" data-tab-row><div class="acpmux-handoff-header-tools">
<div class="acpmux-header-tools">
<button class="acpmux-header-tool" aria-label="Terminal"></button>
<button class="acpmux-header-tool acpmux-header-panel-toggle" aria-pressed="true" aria-label="Session Info"></button>
</div>
</div></header>
</div>
</section>
</body></html>`);
const toggle = page.locator(".acpmux-header-panel-toggle");
const box = (await toggle.boundingBox())!;
// The page's own pixels, with alpha: omitBackground leaves Chromium's canvas transparent.
const shot = await page.screenshot({ type: "png", omitBackground: true });
const { PNG } = await import("pngjs");
const png = PNG.sync.read(shot);
const at = (x: number, y: number) => {
const i = (png.width * y + x) * 4;
return [png.data[i], png.data[i + 1], png.data[i + 2], png.data[i + 3]];
};
// Inside the 40px row, beside and between the tools: nothing at all (alpha 0).
expect(at(10, 20)[3]).toBe(0);
expect(at(Math.round(box.x) - 4, 20)[3]).toBe(0);
expect(at(319, 2)[3]).toBe(0);
// The toggled Session Info button keeps its plate.
expect(at(Math.round(box.x + 3), Math.round(box.y + 3))[3]).toBeGreaterThan(0);
// Below the row: the page's own color.
const below = at(10, 80);
expect(below[0]).toBeGreaterThan(200);
expect(below[3]).toBeGreaterThan(100);
} finally {
await page.close();
}
});
}

test("the loading shell carries the offered row state before the handshake", () => {
const app = read("agent-session/acpmux/App.tsx");
Expand Down
Loading