diff --git a/apps/desktop/src/components/pane-shell/tree/floating-adoption.test.ts b/apps/desktop/src/components/pane-shell/tree/floating-adoption.test.ts new file mode 100644 index 00000000000..4020b03ddce --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/floating-adoption.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.test.tsx new file mode 100644 index 00000000000..b5e97640dfa --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.test.tsx @@ -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('[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) { + disposers.push( + registry.register({ + area: 'panes', + data, + id: 'hud', + render: () =>

live

, + 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() + + 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() + + expect(card()).toBeNull() + }) + + it('moves with a real pointer drag on the header', () => { + registerHud({ anchor: 'top-left', height: '132px', placement: 'floating', width: '224px' }) + render() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + expect(document.querySelectorAll('[data-floating-pane]').length).toBe(2) + }) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.tsx new file mode 100644 index 00000000000..338a2f17b9c --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/floating-panes.tsx @@ -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 => readJson>(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(() => { + 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(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) => { + 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) => { + 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) => { + 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 ( +
+ {/* Header IS the drag handle — the floating equivalent of a tab strip. */} +
+ {pane.title ?? pane.id} + +
+ + {!collapsed && ( +
+ {pane.render?.()} +
+ )} +
+ ) +} + +/** 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 => ( + + ))} + + ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/floating-rect.test.ts b/apps/desktop/src/components/pane-shell/tree/renderer/floating-rect.test.ts new file mode 100644 index 00000000000..dec110531fe --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/floating-rect.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/floating-rect.ts b/apps/desktop/src/components/pane-shell/tree/renderer/floating-rect.ts new file mode 100644 index 00000000000..f09947e8132 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/floating-rect.ts @@ -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 +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx index ddb466b9f26..3145f563d3d 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx @@ -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 }) { `} + {/* Non-tiling panes: fixed cards above the tree, outside every zone. */} + {children} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts index b3785b38476..7dd10012aa3 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts @@ -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 diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index b07f4eb4424..f40427156c6 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -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 diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 285f843dcc2..8d28af45238 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -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