Merge pull request #107239 from KoNit-K/fix/extension-titlebar-hide-fixed-clusters

fix(desktop): hide fixed titlebar clusters on contributed full pages
This commit is contained in:
jonny
2026-09-10 17:55:11 +02:00
committed by GitHub
4 changed files with 150 additions and 16 deletions

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { hidesFixedTitlebarClusters, isOverlayView } from './routes'
describe('hidesFixedTitlebarClusters', () => {
it('hides clusters on contributed full pages and overlays', () => {
expect(hidesFixedTitlebarClusters('extension')).toBe(true)
expect(hidesFixedTitlebarClusters('settings')).toBe(true)
})
it('keeps clusters on chat and first-party workspace pages', () => {
expect(hidesFixedTitlebarClusters('chat')).toBe(false)
expect(hidesFixedTitlebarClusters('skills')).toBe(false)
expect(hidesFixedTitlebarClusters('messaging')).toBe(false)
expect(hidesFixedTitlebarClusters('artifacts')).toBe(false)
})
it('does not treat extension as an overlay', () => {
expect(isOverlayView('extension')).toBe(false)
})
})

View File

@@ -141,6 +141,15 @@ export function isOverlayView(view: AppView): boolean {
return OVERLAY_VIEWS.has(view)
}
/** True when TitlebarControls must hide the app's fixed tool clusters.
* Overlays already own the window (clusters AND titleBar slots unmount).
* Contributed full pages (`extension`) hide the app clusters but keep the
* titleBar slots so plugin chrome can own that space. First-party workspace
* pages (skills/messaging/artifacts) keep the clusters. */
export function hidesFixedTitlebarClusters(view: AppView): boolean {
return isOverlayView(view) || view === 'extension'
}
/** The pathname of a router target. Every classifier below reasons about a
* PATH, but callers navigate to full targets (`/skills?tab=mcp`), and an
* unstripped query reaches the session-id parser — `/skills?tab=mcp` reads as

View File

@@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { registry } from '@/contrib/registry'
import { I18nProvider } from '@/i18n'
import { ROUTES_AREA } from '../routes'
import { TitlebarControls } from './titlebar-controls'
function renderControls(pathname: string) {
return render(
<MemoryRouter initialEntries={[pathname]}>
<I18nProvider configClient={null} initialLocale="en">
<TitlebarControls onOpenSettings={() => {}} />
</I18nProvider>
</MemoryRouter>
)
}
const windowControls = () => screen.queryByLabelText('Window controls')
const appControls = () => screen.queryByLabelText('App controls')
const pluginChrome = () => screen.queryByText('plugin-chrome')
describe('TitlebarControls fixed clusters', () => {
let dispose: () => void
beforeEach(() => {
dispose = registry.registerMany([
{
area: ROUTES_AREA,
data: { path: '/kanban' },
id: 'test-kanban-route',
render: () => null
},
{
area: 'titleBar.center',
id: 'test-plugin-chrome',
render: () => <span>plugin-chrome</span>
}
])
})
afterEach(() => {
dispose()
cleanup()
})
it('hides the app clusters on a contributed full-page route', () => {
renderControls('/kanban')
expect(windowControls()).toBeNull()
expect(appControls()).toBeNull()
})
it('keeps plugin titlebar contributions on a contributed full-page route', () => {
renderControls('/kanban')
expect(pluginChrome()).not.toBeNull()
expect(windowControls()).toBeNull()
expect(appControls()).toBeNull()
})
it('keeps the app clusters on chat', () => {
renderControls('/')
expect(windowControls()).not.toBeNull()
expect(appControls()).not.toBeNull()
})
it('hides the app clusters on an overlay', () => {
renderControls('/settings')
expect(windowControls()).toBeNull()
expect(appControls()).toBeNull()
})
it('hides plugin titlebar contributions on an overlay', () => {
renderControls('/settings')
expect(pluginChrome()).toBeNull()
})
it('keeps the app clusters on a first-party workspace page', () => {
renderControls('/skills')
expect(windowControls()).not.toBeNull()
expect(appControls()).not.toBeNull()
})
})

View File

@@ -25,7 +25,7 @@ import {
} from '@/store/layout'
import { $unreadSessionCount } from '@/store/session-dot-state'
import { appViewForPath, isOverlayView } from '../routes'
import { appViewForPath, hidesFixedTitlebarClusters, isOverlayView } from '../routes'
import {
TITLEBAR_ICON_BADGE_SCALE,
@@ -235,31 +235,43 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
}
]
// While a full-screen overlay (settings, command center, …) is open it should
// visually own the window. These control clusters are `fixed` at a higher
// z-index than the overlay card, so they'd otherwise bleed over it — hide them
// and let the overlay's own chrome (close button, drag region) take over.
if (isOverlayView(appViewForPath(location.pathname))) {
const view = appViewForPath(location.pathname)
// Overlays own the window. These clusters are `fixed` at a higher z-index
// than the overlay card, so they'd otherwise bleed over it — hide them (and
// the nested titleBar slots) and let the overlay's own chrome take over.
if (isOverlayView(view)) {
return null
}
const titlebarSlots = (
<>
<Slot area="titleBar.left" />
<Slot area="titleBar.center" />
<Slot area="titleBar.right" />
</>
)
const leftClusterClass = cn(
titlebarToolClusterClass,
'left-(--titlebar-controls-left) top-(--titlebar-controls-top) translate-y-(--titlebar-controls-y-nudge)'
)
// Contributed full-context plugin pages (`extension`) own the titlebar band.
// Hide the app's tool clusters but keep plugin slots in the same fixed
// position so `titleBar.center` (e.g. kanban's board switcher) stays mounted.
if (hidesFixedTitlebarClusters(view)) {
return <div className={leftClusterClass}>{titlebarSlots}</div>
}
const visibleLeftTools = [sidebarTool, ...systemTools, ...leftTools, ...tools].filter(tool => !tool.hidden)
return (
<>
<div
aria-label={t.shell.windowControls}
className={cn(
titlebarToolClusterClass,
'left-(--titlebar-controls-left) top-(--titlebar-controls-top) translate-y-(--titlebar-controls-y-nudge)'
)}
>
<div aria-label={t.shell.windowControls} className={leftClusterClass}>
{visibleLeftTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
<Slot area="titleBar.left" />
<Slot area="titleBar.center" />
<Slot area="titleBar.right" />
{titlebarSlots}
</div>
<div