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:
21
apps/desktop/src/app/routes.titlebar-clusters.test.ts
Normal file
21
apps/desktop/src/app/routes.titlebar-clusters.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
92
apps/desktop/src/app/shell/titlebar-controls.test.tsx
Normal file
92
apps/desktop/src/app/shell/titlebar-controls.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user