Merge pull request #73143 from NousResearch/bb/floating-panes

feat(desktop): floating pane placement
This commit is contained in:
brooklyn! 2026-07-28 01:18:36 -05:00 committed by GitHub
commit ff06b47ab0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 728 additions and 7 deletions

View file

@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// A `placement: 'floating'` pane must never enter the layout tree. Adoption is
// what turns a contribution into a track (and therefore into something that
// steals width from a zone), so this exercises the real `adoptContributedPanes`
// path via `watchContributedPanes` rather than asserting on the filter itself.
describe('floating panes stay out of the layout tree', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
afterEach(() => {
vi.resetModules()
})
async function setup() {
const tree = await import('@/components/pane-shell/tree/store')
const model = await import('@/components/pane-shell/tree/model')
const { registry } = await import('@/contrib/registry')
registry.register({ id: 'workspace', area: 'panes', title: 'chat', data: { placement: 'main' }, render: () => null })
tree.declareDefaultTree(model.group(['workspace'], { id: 'grp-main' }))
return { model, registry, tree }
}
it('does not adopt a floating pane, while still adopting a docked one', async () => {
const { model, registry, tree } = await setup()
registry.register({
id: 'hud',
area: 'panes',
title: 'HUD',
data: { placement: 'floating', anchor: 'top-right', width: '240px' },
render: () => null
})
registry.register({
id: 'files',
area: 'panes',
title: 'Files',
data: { placement: 'right' },
render: () => null
})
tree.watchContributedPanes()
const ids = model.allPaneIds(tree.$layoutTree.get()!)
expect(ids).not.toContain('hud')
// Control: a normal placement DOES get adopted through the same pass, so
// the exclusion is specific to 'floating', not a broken adoption run.
expect(ids).toContain('files')
})
it('keeps the main zone unsplit when only a floating pane registers', async () => {
const { model, registry, tree } = await setup()
registry.register({
id: 'hud',
area: 'panes',
title: 'HUD',
data: { placement: 'floating' },
render: () => null
})
tree.watchContributedPanes()
const root = tree.$layoutTree.get()!
// Still the single group it was declared as — no track was created.
expect(root.type).toBe('group')
expect(model.allPaneIds(root)).toEqual(['workspace'])
})
it('survives a registry change without ever adopting the floating pane', async () => {
const { model, registry, tree } = await setup()
registry.register({ id: 'hud', area: 'panes', data: { placement: 'floating' }, render: () => null })
tree.watchContributedPanes()
// A later registration re-runs adoption via the registry subscription.
registry.register({ id: 'terminal', area: 'panes', data: { placement: 'bottom' }, render: () => null })
const ids = model.allPaneIds(tree.$layoutTree.get()!)
expect(ids).toContain('terminal')
expect(ids).not.toContain('hud')
})
})

View file

@ -0,0 +1,203 @@
/**
* Live-behavior test for FloatingPanes: mounts the REAL component into a real
* DOM and drives real pointer/resize events. This is the closest thing to
* "running it" without an Electron main process it exercises the rendered
* element's computed geometry, not the pure geometry module (that's
* floating-rect.test.ts).
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registry } from '@/contrib/registry'
import { FloatingPanes } from './floating-panes'
let root: null | Root = null
let container: HTMLDivElement | null = null
let disposers: (() => void)[] = []
function render(ui: ReactNode) {
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
act(() => {
root!.render(ui)
})
}
const card = () => document.querySelector<HTMLElement>('[data-floating-pane="hud"]')
const grab = () => card()!.querySelector('header')!
/** jsdom has no real pointer events; PointerEvent falls back to MouseEvent. */
function pointer(target: Element, type: string, x: number, y: number) {
const event = new MouseEvent(type, { bubbles: true, clientX: x, clientY: y })
Object.defineProperty(event, 'pointerId', { value: 1 })
act(() => {
target.dispatchEvent(event)
})
}
function resizeWindow(width: number, height: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: height, writable: true })
act(() => {
window.dispatchEvent(new Event('resize'))
})
}
function registerHud(data: Record<string, unknown>) {
disposers.push(
registry.register({
area: 'panes',
data,
id: 'hud',
render: () => <p data-testid="hud-body">live</p>,
title: 'HUD'
})
)
}
describe('FloatingPanes (live DOM)', () => {
beforeEach(() => {
window.localStorage.clear()
resizeWindow(1440, 900)
// setPointerCapture / releasePointerCapture don't exist in jsdom.
Element.prototype.setPointerCapture = vi.fn()
Element.prototype.releasePointerCapture = vi.fn()
})
afterEach(() => {
if (root) {
act(() => root!.unmount())
}
container?.remove()
root = null
container = null
disposers.forEach(dispose => dispose())
disposers = []
})
it('mounts a fixed card in the anchored corner with the pane body inside', () => {
registerHud({ anchor: 'top-right', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)
const el = card()!
expect(el).toBeTruthy()
expect(el.className).toContain('fixed')
// 1440 - 224 - 12 margin = 1204; titlebar 34 + 12 = 46.
expect(el.style.left).toBe('1204px')
expect(el.style.top).toBe('46px')
expect(el.style.width).toBe('224px')
expect(document.querySelector('[data-testid="hud-body"]')?.textContent).toBe('live')
})
it('renders nothing for a non-floating placement', () => {
registerHud({ placement: 'right', width: '224px' })
render(<FloatingPanes />)
expect(card()).toBeNull()
})
it('moves with a real pointer drag on the header', () => {
registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)
expect(card()!.style.left).toBe('12px')
pointer(grab(), 'pointerdown', 100, 100)
pointer(grab(), 'pointermove', 260, 240)
pointer(grab(), 'pointerup', 260, 240)
expect(card()!.style.left).toBe('172px')
expect(card()!.style.top).toBe('186px')
})
it('persists the dragged position across a remount', () => {
registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)
pointer(grab(), 'pointerdown', 100, 100)
pointer(grab(), 'pointermove', 300, 300)
pointer(grab(), 'pointerup', 300, 300)
const moved = card()!.style.left
act(() => root!.unmount())
container!.remove()
render(<FloatingPanes />)
expect(card()!.style.left).toBe(moved)
})
it('rides the right edge when the window shrinks', () => {
registerHud({ anchor: 'top-right', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)
expect(card()!.style.left).toBe('1204px')
resizeWindow(1000, 700)
// Tracks the edge: 1000 - 224 - 12 = 764.
expect(card()!.style.left).toBe('764px')
})
it('never lets a drag push the card under the titlebar', () => {
registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)
pointer(grab(), 'pointerdown', 100, 100)
pointer(grab(), 'pointermove', 100, -900)
pointer(grab(), 'pointerup', 100, -900)
expect(Number.parseFloat(card()!.style.top)).toBeGreaterThanOrEqual(34)
})
it('collapses to the header and drops the body, and does not drag from the button', () => {
registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' })
render(<FloatingPanes />)
const before = card()!.style.left
const toggle = card()!.querySelector('button')!
// The button is inside the drag handle — [data-floating-no-drag] must
// stop it starting a drag.
pointer(toggle, 'pointerdown', 100, 100)
pointer(grab(), 'pointermove', 400, 400)
pointer(grab(), 'pointerup', 400, 400)
expect(card()!.style.left).toBe(before)
act(() => {
toggle.click()
})
expect(document.querySelector('[data-testid="hud-body"]')).toBeNull()
expect(card()!.style.height).toBe('')
})
it('renders one card per floating contribution', () => {
registerHud({ anchor: 'top-left', placement: 'floating', width: '224px' })
disposers.push(
registry.register({
area: 'panes',
data: { anchor: 'bottom-right', placement: 'floating', width: '200px' },
id: 'hud2',
render: () => null,
title: 'HUD 2'
})
)
render(<FloatingPanes />)
expect(document.querySelectorAll('[data-floating-pane]').length).toBe(2)
})
})

View file

@ -0,0 +1,206 @@
/**
* Floating panes the tree's non-tiling placement.
*
* `placement: 'floating'` opts a pane OUT of the layout tree: it never becomes
* a track, never takes width from a zone, and never appears in a tab strip.
* The tree renders it as a fixed card above itself, draggable by its header,
* with position + collapse persisted per pane id. The geometry rules live in
* floating-rect.ts.
*/
import { useStore } from '@nanostores/react'
import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'
import { HUD_SURFACE } from '@/app/floating-hud'
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
import { Codicon } from '@/components/ui/codicon'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import type { Contribution } from '@/contrib/types'
import { readJson, writeJson } from '@/lib/storage'
import { cn } from '@/lib/utils'
import { $hiddenTreePanes } from '../store'
import {
anchoredRect,
clampFloatingRect,
FLOATING_PLACEMENT,
floatingPx,
type FloatingRect,
type FloatingViewport,
reflowRect
} from './floating-rect'
import { paneChrome } from './track-model'
const POSITIONS_KEY = 'hermes.desktop.floatingPanes.v1'
const DEFAULT_SIZE = { width: 240, height: 180 }
interface StoredRect {
x: number
y: number
collapsed?: boolean
}
const readStored = (): Record<string, StoredRect> => readJson<Record<string, StoredRect>>(POSITIONS_KEY) ?? {}
const viewportNow = (): FloatingViewport => ({
width: window.innerWidth,
height: window.innerHeight,
top: TITLEBAR_HEIGHT
})
function FloatingPane({ pane }: { pane: Contribution }) {
const chrome = paneChrome(pane)
const anchor = chrome.anchor ?? 'top-right'
const size = {
width: floatingPx(chrome.width, DEFAULT_SIZE.width),
height: floatingPx(chrome.height, DEFAULT_SIZE.height)
}
const [rect, setRect] = useState<FloatingRect>(() => {
const stored = readStored()[pane.id]
const spawned = anchoredRect(anchor, size, viewportNow())
return stored ? { ...spawned, x: stored.x, y: stored.y } : spawned
})
const [collapsed, setCollapsed] = useState(() => readStored()[pane.id]?.collapsed ?? false)
const drag = useRef<{ x: number; y: number } | null>(null)
const viewport = useRef<FloatingViewport>(viewportNow())
const persist = useCallback(
(next: FloatingRect, nextCollapsed: boolean) => {
writeJson(POSITIONS_KEY, { ...readStored(), [pane.id]: { x: next.x, y: next.y, collapsed: nextCollapsed } })
},
[pane.id]
)
// Track the viewport so an edge-anchored pane rides its edge on resize.
// The previous-size read lives in the handler (not a useEffect body): it's
// window geometry, not a mirrored reactive value.
const handleResize = useCallback(() => {
const next = viewportNow()
setRect(current => reflowRect(current, anchor, viewport.current, next))
viewport.current = next
}, [anchor])
useEffect(() => {
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [handleResize])
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
if ((event.target as HTMLElement).closest('[data-floating-no-drag]')) {
return
}
event.currentTarget.setPointerCapture(event.pointerId)
drag.current = { x: event.clientX, y: event.clientY }
event.preventDefault()
}, [])
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
const from = drag.current
if (!from) {
return
}
drag.current = { x: event.clientX, y: event.clientY }
setRect(current =>
clampFloatingRect(
{ ...current, x: current.x + event.clientX - from.x, y: current.y + event.clientY - from.y },
viewport.current
)
)
}, [])
const onPointerUp = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (!drag.current) {
return
}
drag.current = null
event.currentTarget.releasePointerCapture?.(event.pointerId)
setRect(current => {
persist(current, collapsed)
return current
})
},
[collapsed, persist]
)
const toggleCollapsed = () =>
setCollapsed(current => {
persist(rect, !current)
return !current
})
return (
<div
className={cn('pointer-events-auto fixed z-45 flex flex-col overflow-hidden', HUD_SURFACE)}
data-floating-pane={pane.id}
style={{
left: rect.x,
top: rect.y,
width: size.width,
height: collapsed ? undefined : size.height
}}
>
{/* Header IS the drag handle — the floating equivalent of a tab strip. */}
<header
className="flex shrink-0 cursor-grab items-center justify-between gap-2 px-2.5 py-1.5 text-[0.6875rem] text-(--ui-text-secondary) select-none"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{ touchAction: 'none' }}
>
<span className="truncate font-medium">{pane.title ?? pane.id}</span>
<button
className="rounded p-0.5 text-(--ui-text-quaternary) transition-colors hover:text-(--ui-text-primary)"
data-floating-no-drag=""
onClick={toggleCollapsed}
type="button"
>
<Codicon name={collapsed ? 'chevron-down' : 'chevron-up'} size="0.75rem" />
</button>
</header>
{!collapsed && (
<div className="min-h-0 flex-1 overflow-auto">
<ContribBoundary id={pane.id}>{pane.render?.()}</ContribBoundary>
</div>
)}
</div>
)
}
/** Every `placement: 'floating'` contribution, rendered above the tree. */
export function FloatingPanes() {
const panes = useContributions('panes')
const hidden = useStore($hiddenTreePanes)
const floating = panes.filter(pane => paneChrome(pane).placement === FLOATING_PLACEMENT && !hidden.has(pane.id))
if (floating.length === 0) {
return null
}
return (
<>
{floating.map(pane => (
<FloatingPane key={`${pane.source ?? 'core'}:${pane.id}`} pane={pane} />
))}
</>
)
}

View file

@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { anchoredRect, clampFloatingRect, floatingPx, reflowRect } from './floating-rect'
const viewport = { width: 1440, height: 900, top: 34 }
const size = { width: 240, height: 180 }
describe('anchoredRect', () => {
it('spawns in the named corner, inset by the margin', () => {
expect(anchoredRect('top-right', size, viewport)).toMatchObject({ x: 1188, y: 46 })
expect(anchoredRect('top-left', size, viewport)).toMatchObject({ x: 12, y: 46 })
expect(anchoredRect('bottom-right', size, viewport)).toMatchObject({ x: 1188, y: 708 })
expect(anchoredRect('bottom-left', size, viewport)).toMatchObject({ x: 12, y: 708 })
})
it('never spawns over the reserved top chrome', () => {
for (const anchor of ['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const) {
expect(anchoredRect(anchor, size, { width: 320, height: 120, top: 34 }).y).toBeGreaterThanOrEqual(34)
}
})
})
describe('clampFloatingRect', () => {
it('keeps a grabbable sliver on screen at either horizontal edge', () => {
expect(clampFloatingRect({ ...size, x: 5000, y: 100 }, viewport).x).toBe(1392)
expect(clampFloatingRect({ ...size, x: -5000, y: 100 }, viewport).x).toBe(-192)
})
it('hard-bounds the top so the drag handle is always reachable', () => {
expect(clampFloatingRect({ ...size, x: 100, y: -500 }, viewport).y).toBe(34)
expect(clampFloatingRect({ ...size, x: 100, y: 5000 }, viewport).y).toBe(852)
})
it('pins to the top-left rather than inverting when larger than the viewport', () => {
const tiny = { width: 200, height: 100, top: 34 }
const rect = clampFloatingRect({ x: 0, y: 0, width: 400, height: 400 }, tiny)
expect(rect.x).toBeLessThanOrEqual(tiny.width)
expect(rect.y).toBe(34)
})
it('leaves an in-bounds rect untouched', () => {
const rect = { ...size, x: 400, y: 200 }
expect(clampFloatingRect(rect, viewport)).toEqual(rect)
})
})
describe('reflowRect', () => {
const shrunk = { width: 1000, height: 700, top: 34 }
it('rides the right edge when right-anchored', () => {
const start = anchoredRect('top-right', size, viewport)
expect(reflowRect(start, 'top-right', viewport, shrunk).x).toBe(748)
})
it('rides the bottom edge when bottom-anchored', () => {
const start = anchoredRect('bottom-left', size, viewport)
expect(reflowRect(start, 'bottom-left', viewport, shrunk).y).toBe(508)
})
it('holds position when left/top-anchored, but still clamps into view', () => {
const start = { ...size, x: 40, y: 60 }
expect(reflowRect(start, 'top-left', viewport, shrunk)).toMatchObject({ x: 40, y: 60 })
expect(reflowRect({ ...size, x: 1300, y: 60 }, 'top-left', viewport, shrunk).x).toBe(952)
})
it('is identity for an in-bounds rect when the viewport did not change', () => {
const rect = { ...size, x: 300, y: 300 }
expect(reflowRect(rect, 'top-right', viewport, viewport)).toEqual(rect)
})
})
describe('floatingPx', () => {
it('accepts authored px strings and raw numbers, falling back otherwise', () => {
expect(floatingPx('216px', 240)).toBe(216)
expect(floatingPx(300, 240)).toBe(300)
expect(floatingPx(undefined, 240)).toBe(240)
expect(floatingPx('auto', 240)).toBe(240)
expect(floatingPx(Number.NaN, 240)).toBe(240)
})
})

View file

@ -0,0 +1,109 @@
/**
* Floating-pane geometry pure, so the clamping/anchoring rules are testable
* without a DOM.
*
* A floating pane is NOT a track in the layout tree: it never takes width from
* a zone. It's a fixed-position card the tree renders above itself. Its whole
* contract is "stay a sane rect inside the viewport", which is this module.
*/
/** Corner a floating pane spawns from (and re-anchors to on reset). */
export type FloatingAnchor = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right'
export interface FloatingRect {
x: number
y: number
width: number
height: number
}
export interface FloatingViewport {
width: number
height: number
/** Chrome reserved at the top (the 34px titlebar) — panes never cover it. */
top: number
}
/** Keep this much of the card on screen when clamping, so it stays grabbable. */
const MIN_VISIBLE = 48
/** Gap between a spawned pane and the viewport edge it anchors to. */
export const FLOATING_MARGIN = 12
/** The one non-tiling placement — see renderer/floating-panes.tsx. */
export const FLOATING_PLACEMENT = 'floating'
export const clamp = (n: number, lo: number, hi: number): number => Math.min(Math.max(n, lo), hi)
/**
* Clamp a rect into the viewport. Horizontal keeps `MIN_VISIBLE` px on screen
* from either edge (a card can hang off the right, never vanish); vertical is
* hard-bounded by the reserved chrome so the drag handle is always reachable.
*
* Both axes clamp `lo` before `hi`, so a card wider/taller than the viewport
* pins to the top-left rather than inverting.
*/
export function clampFloatingRect(rect: FloatingRect, viewport: FloatingViewport): FloatingRect {
const maxX = Math.max(MIN_VISIBLE - rect.width, viewport.width - MIN_VISIBLE)
const maxY = Math.max(viewport.top, viewport.height - MIN_VISIBLE)
return {
...rect,
x: clamp(rect.x, Math.min(MIN_VISIBLE - rect.width, maxX), maxX),
y: clamp(rect.y, viewport.top, maxY)
}
}
/** Spawn position for an anchor — the corner, inset by `FLOATING_MARGIN`. */
export function anchoredRect(
anchor: FloatingAnchor,
size: { width: number; height: number },
viewport: FloatingViewport
): FloatingRect {
const right = anchor === 'bottom-right' || anchor === 'top-right'
const bottom = anchor === 'bottom-left' || anchor === 'bottom-right'
const rect = {
...size,
x: right ? viewport.width - size.width - FLOATING_MARGIN : FLOATING_MARGIN,
y: bottom ? viewport.height - size.height - FLOATING_MARGIN : viewport.top + FLOATING_MARGIN
}
return clampFloatingRect(rect, viewport)
}
/**
* Re-clamp on viewport resize. A pane anchored to a right/bottom edge TRACKS
* that edge (shrinking the window keeps it in the corner) instead of being
* dragged inward only when it would fall off matching how the pet and every
* OS HUD behave.
*/
export function reflowRect(
rect: FloatingRect,
anchor: FloatingAnchor,
previous: FloatingViewport,
next: FloatingViewport
): FloatingRect {
const right = anchor === 'bottom-right' || anchor === 'top-right'
const bottom = anchor === 'bottom-left' || anchor === 'bottom-right'
return clampFloatingRect(
{
...rect,
x: right ? rect.x + (next.width - previous.width) : rect.x,
y: bottom ? rect.y + (next.height - previous.height) : rect.y
},
next
)
}
/** Parse an authored CSS px length (`'216px'`, `216`) with a fallback. */
export function floatingPx(value: number | string | undefined, fallback: number): number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : fallback
}
const parsed = Number.parseFloat(value ?? '')
return Number.isFinite(parsed) ? parsed : fallback
}

View file

@ -29,6 +29,7 @@ import { $layoutTree, trackActiveTreeGroup } from '../store'
import { ZoneEditor } from '../zone-editor'
import { TreeEditBar } from './edit-bar'
import { FloatingPanes } from './floating-panes'
import { NarrowOverlays } from './narrow-overlays'
import { TreeNode } from './tree-node'
@ -73,6 +74,8 @@ export function LayoutTreeRoot({ children }: { children?: ReactNode }) {
`}</style>
<TreeNode node={tree} root rootRow={tree.type === 'split' && tree.orientation === 'row'} />
<NarrowOverlays />
{/* Non-tiling panes: fixed cards above the tree, outside every zone. */}
<FloatingPanes />
<TreeEditBar />
<ZoneEditor />
{children}

View file

@ -15,6 +15,7 @@ import type { GroupNode, LayoutNode } from '../model'
import { allPaneIds } from '../model'
import type { DoubleTapContext } from './drag-session'
import type { FloatingAnchor } from './floating-rect'
export const MIN_PANE_PX = 80
@ -34,13 +35,20 @@ export interface PaneSizing {
}
/** Chrome behavior flags a pane contributes. Read via `paneChrome`. */
interface PaneChrome {
interface PaneChrome extends PaneSizing {
/** Leaves the grid on narrow viewports; revealed as an edge overlay. */
collapsible?: boolean
/** Extra ids accepted from PANE_TOGGLE_REVEAL_EVENT (the real app's pane
* ids, e.g. `chat-sidebar` for `sessions`). */
revealAliases?: string[]
/** Tiling role in the tree, or `'floating'` the one NON-tiling placement:
* the pane is excluded from the tree entirely and rendered as a fixed card
* above it (see renderer/floating-panes.tsx). A floating pane takes no
* space from any zone, has no tab, and can't be docked or split. */
placement?: string
/** Spawn corner for `placement: 'floating'` (default `'top-right'`). The
* pane also TRACKS that corner's edges when the window resizes. */
anchor?: FloatingAnchor
/** No Close in the tab menu the one surface the app can't lose (the
* main workspace). Session tiles share `placement: 'main'` but close. */
uncloseable?: boolean

View file

@ -38,6 +38,7 @@ import {
splitGroupZone as splitGroupZoneOp,
type SplitNode
} from './model'
import { FLOATING_PLACEMENT } from './renderer/floating-rect'
import { rootChildSide } from './renderer/track-model'
// v2: v1 trees were saved against placeholder panes with index-order zone
@ -837,7 +838,14 @@ function adoptContributedPanes(): void {
}
const dismissed = $dismissedPanes.get()
const missing = panes.filter(c => !inTree.has(c.id) && !dismissed.has(c.id))
// `placement: 'floating'` opts OUT of the tree entirely — those panes render
// as fixed cards above it (renderer/floating-panes.tsx). Adopting one would
// turn it into a track that steals width from a zone, which is the whole
// thing floating exists to avoid.
const missing = panes.filter(
c => !inTree.has(c.id) && !dismissed.has(c.id) && placementOf(c.id) !== FLOATING_PLACEMENT
)
if (missing.length === 0) {
return

View file

@ -125,6 +125,12 @@ export { type RouteContribution, ROUTES_AREA, SIDEBAR_NAV_AREA, type SidebarNavC
export type { StatusbarItem } from '@/app/shell/statusbar-controls'
export type { TitlebarTool } from '@/app/shell/titlebar-controls'
/** Pane placement roles. `'floating'` is the one NON-tiling value: the pane is
* excluded from the layout tree and rendered as a fixed, draggable card above
* it it takes no width from any zone, has no tab, and can't be docked.
* Pair it with `anchor` (spawn corner, default `'top-right'`) plus
* `width`/`height`. */
export type { FloatingAnchor } from '@/components/pane-shell/tree/renderer/floating-rect'
export { StatusDot, type StatusTone } from '@/components/status-dot'
export { Badge } from '@/components/ui/badge'
export { Button } from '@/components/ui/button'
@ -177,6 +183,9 @@ export { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
export { Textarea } from '@/components/ui/textarea'
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
export type { GatewayEventListener } from '@/contrib/events'
// -- contracts ----------------------------------------------------------------
export type {
HermesPlugin,
PluginContext,
@ -184,9 +193,6 @@ export type {
PluginRestOptions,
PluginStorage
} from '@/contrib/plugin'
// -- contracts ----------------------------------------------------------------
/** Mount-scoped contribution: while the rendering component is mounted, its
* children render in the target area's slot; unmount disposes it. Use for
* page-owned chrome (a page's titlebar control leaves with the page)
@ -215,12 +221,12 @@ export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
* authors) + its translucent tag fill so plugin-rendered identities read
* the same hue as everywhere else. */
export { profileColor, profileColorSoft } from '@/lib/profile-color'
export const PANES_AREA = 'panes'
/** The shared client itself, for invalidation OUTSIDE React (e.g. a
* `ctx.socket` frame invalidating a query). Inside components keep using
* `useQueryClient`. */
export { queryClient } from '@/lib/query-client'
export const PANES_AREA = 'panes'
export const STATUSBAR_AREAS = { left: 'statusBar.left', right: 'statusBar.right' } as const
export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left', right: 'titleBar.right' } as const