mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #73143 from NousResearch/bb/floating-panes
feat(desktop): floating pane placement
This commit is contained in:
commit
ff06b47ab0
9 changed files with 728 additions and 7 deletions
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue