fix(desktop): scope composer pop-out to its layout zone

Pop-out was one flag and one position for the whole window, and every
keep-alive-mounted tab re-clamped that position against its own rect and
wrote it back. So floating the composer in one pane floated it in every
pane, and N surfaces raced for one value with the last writer winning — a
drag in one tab was lost in the next.

State is now keyed by layout group, the scope users actually mean: tabs in
a zone share a float, a split zone beside them keeps its own. Within a
zone the stored position is intent, and each surface derives what it
renders through the pure clampPopoutPosition against its own rect.

Re-placing is gated on pane visibility so a live drag can't force a reflow
in every background tab, and runs pre-paint so a revealed tab never shows
a stale frame. Storage is written on release rather than per drag frame,
dead zones are pruned against the live tree, and the pre-zone value seeds
one first read so an existing float survives the upgrade without leaking
into zones split later.
This commit is contained in:
Brooklyn Nicholson 2026-07-28 02:50:02 -05:00
parent 25ff775136
commit df25d77131
5 changed files with 416 additions and 87 deletions

View file

@ -9,8 +9,6 @@ import {
} from '@/app/chat/surface-vars'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { $composerPoppedOut } from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
@ -82,6 +80,11 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
const lastBucketedSurfaceHeightRef = useRef(0)
const lastTightRef = useRef<boolean | null>(null)
const lastCompactPillRef = useRef<boolean | null>(null)
// Mirrored into a ref so `syncComposerMetrics` stays referentially stable —
// it's the shared ResizeObserver's handler, and a new identity every render
// would re-register the observation.
const poppedOutRef = useRef(poppedOut)
poppedOutRef.current = poppedOut
const syncComposerMetrics = useCallback(() => {
const composer = composerRef.current
@ -92,9 +95,10 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
// Floating composer is out of the thread's flow — it must not reserve any
// bottom clearance. Zero the measured vars so the thread reclaims the space.
// (Read globals here so the callback stays stable; mirror the popoutAllowed
// gate since secondary windows are forced docked.)
if ($composerPoppedOut.get() && !isSecondaryWindow()) {
// Read through a ref so the callback stays stable, and read THIS surface's
// own state: pop-out is per layout zone, so a float in the left split must
// not zero the right split's clearance.
if (poppedOutRef.current) {
lastBucketedHeightRef.current = 0
lastBucketedSurfaceHeightRef.current = 0
setSurfaceVar(composer, COMPOSER_HEIGHT_VAR, '0px')

View file

@ -1,18 +1,20 @@
import { useStore } from '@nanostores/react'
import { type RefObject, useCallback, useEffect } from 'react'
import { type RefObject, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { usePaneGroup, usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { triggerHaptic } from '@/lib/haptics'
import {
$composerPopoutPosition,
$composerPoppedOut,
$composerPopoutZone,
clampPopoutPosition,
getComposerPopoutZone,
popoutBoundsElement,
type PopoutPosition,
readPopoutBounds,
setComposerPopoutPosition,
setComposerPoppedOut
} from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
import { useComposerScope } from '../scope'
import { useComposerPopoutGestures } from './use-popout-drag'
interface UseComposerPopoutOptions {
@ -20,32 +22,122 @@ interface UseComposerPopoutOptions {
}
/**
* Pop-out engine: the dockedfloating state (a shared, persisted atom), the
* dock/float/toggle actions, the drag gestures, and the on-screen re-clamp.
* Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) can't
* pop out a floating composer makes no sense there and would yank the main
* window's composer out via the shared atom.
* This surface's on-screen placement, derived from its zone's drag intent.
*
* A zone stores one intent for its whole tab stack drag the box in any tab and
* it moves in all of them but each surface owns a different rect, so the
* intent is clamped per surface. Clamping into the store instead would have
* every keep-alive-mounted tab overwrite the others with a position bounded by
* ITS geometry, last writer winning: that's how a drag in one tab used to get
* lost in another.
*
* Re-placing is skipped while this surface drags (the gesture already clamped
* against this rect) and while it's an inactive tab (still mounted, so a live
* drag would otherwise force a reflow per background tab per frame).
*/
function usePopoutPlacement(
composerRef: RefObject<HTMLFormElement | null>,
groupId: string,
intent: PopoutPosition,
dragging: boolean,
poppedOut: boolean
): PopoutPosition {
const [placement, setPlacement] = useState(intent)
const visible = usePaneVisible()
// Re-place while this surface is the visible tab and isn't itself dragging.
const live = poppedOut && visible && !dragging
// Resolved before the shared ResizeObserver below registers (hook order puts
// this layout effect first), so the observer always has this surface's own
// bounds element rather than a document-wide first match.
const boundsRef = useRef<Element | null>(null)
useLayoutEffect(() => {
boundsRef.current = popoutBoundsElement(composerRef.current)
})
const reclamp = useCallback(() => {
const el = composerRef.current
if (!el) {
return
}
const size = { height: el.offsetHeight, width: el.offsetWidth }
const next = clampPopoutPosition(getComposerPopoutZone(groupId).position, size, readPopoutBounds(el))
// Bail on an unchanged placement: a sash drag resizes the surface every
// frame, and a fresh object each time re-renders the whole composer.
setPlacement(prev => (prev.bottom === next.bottom && prev.right === next.right ? prev : next))
}, [composerRef, groupId])
// The surface resizing (sash drag, sidebar open, tab split) re-places the box
// against its new rect; the composer resizing (a growing draft) re-places it
// against its new height.
useResizeObserver(
useCallback(() => {
if (live) {
reclamp()
}
}, [live, reclamp]),
composerRef,
boundsRef
)
// useLayoutEffect, not useEffect: a tab revealed after the box was dragged in
// another one must not paint a frame at its stale placement before catching
// up. Runs before paint, and no-ops for hidden tabs (`live`).
useLayoutEffect(() => {
if (!live) {
return undefined
}
reclamp()
// A second pass after layout settles (sidebar widths, fonts): anyone
// restored out of bounds is pulled back even if the first measure was
// premature.
const raf = requestAnimationFrame(reclamp)
window.addEventListener('resize', reclamp)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('resize', reclamp)
}
}, [intent, live, reclamp])
return dragging ? intent : placement
}
/**
* Pop-out engine: the dockedfloating state, the dock/float/toggle actions, the
* drag gestures, and this surface's placement.
*
* State is scoped to the surface's layout ZONE (its tab stack): tabs in the same
* zone share one float, so switching tabs keeps the box exactly where you put
* it, while a split zone beside them keeps its own popping out on the left
* doesn't fling a composer out of the right.
*
* Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) stay
* docked: a floating composer makes no sense in a scratch window.
*/
export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
// The floating composer is a window-level singleton: only the main scope
// (not tiles) in a primary window may pop out.
const scope = useComposerScope()
const popoutAllowed = !isSecondaryWindow() && scope.popoutAllowed
const poppedOut = useStore($composerPoppedOut) && popoutAllowed
const popoutPosition = useStore($composerPopoutPosition)
const popoutAllowed = !isSecondaryWindow()
const groupId = usePaneGroup()
const zone = useStore(useMemo(() => $composerPopoutZone(groupId), [groupId]))
const poppedOut = zone.poppedOut && popoutAllowed
const handleComposerPopOut = useCallback(() => {
triggerHaptic('open')
setComposerPoppedOut(true)
}, [])
setComposerPoppedOut(groupId, true)
}, [groupId])
const handleComposerDock = useCallback(() => {
triggerHaptic('success')
setComposerPoppedOut(false)
}, [])
setComposerPoppedOut(groupId, false)
}, [groupId])
// Double-click the grab area toggles dock/float. Undocking restores the last
// position (the persisted atom is never cleared on dock).
// position (a zone's stored position is never cleared on dock).
const handleComposerToggle = useCallback(() => {
poppedOut ? handleComposerDock() : handleComposerPopOut()
}, [handleComposerDock, handleComposerPopOut, poppedOut])
@ -56,39 +148,14 @@ export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
onPointerDown: onComposerGesturePointerDown
} = useComposerPopoutGestures({
composerRef,
groupId,
onDock: handleComposerDock,
onPopOut: handleComposerPopOut,
poppedOut,
position: popoutPosition
position: zone.position
})
// Keep the floating box on-screen: re-clamp (with the real measured size +
// thread bounds) when it pops out and on every window resize — so a position
// persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar
// can never strand it. The rAF pass re-clamps after layout settles (sidebar
// widths, fonts), so anyone loading in out of bounds is pulled back + saved
// even if the first measure was premature.
useEffect(() => {
if (!poppedOut) {
return undefined
}
const reclamp = (persist: boolean) => {
const el = composerRef.current
const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined
setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size })
}
reclamp(true)
const raf = requestAnimationFrame(() => reclamp(true))
const onResize = () => reclamp(false)
window.addEventListener('resize', onResize)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('resize', onResize)
}
}, [composerRef, poppedOut])
const popoutPosition = usePopoutPlacement(composerRef, groupId, zone.position, dragging, poppedOut)
return {
dockProximity,

View file

@ -8,7 +8,7 @@ import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/co
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
import { IdleMount } from '@/components/idle-mount'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { allPaneIds, group, split } from '@/components/pane-shell/tree/model'
import { allPaneIds, group, groupLeafIds, split } from '@/components/pane-shell/tree/model'
import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer'
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
import {
@ -41,6 +41,7 @@ import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
import { LayoutDashboard, PanelBottom } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
import { Codecs, persistentAtom } from '@/lib/persisted'
import { pruneComposerPopoutZones } from '@/store/composer-popout'
import {
$fileBrowserOpen,
$panesFlipped,
@ -415,6 +416,15 @@ watchContributedPanes()
watchSessionTiles()
watchRouteTiles()
// Composer pop-out state is keyed by layout zone, so drop entries for zones the
// user has since closed or merged away — otherwise a long-lived install keeps a
// row for every split it has ever had.
$layoutTree.subscribe(tree => {
if (tree) {
pruneComposerPopoutZones(groupLeafIds(tree))
}
})
// Mirror sidebar pins into the backend keep-flag so the auto-archive sweep
// never hides a pinned chat (and pre-existing pins migrate transparently).
watchSessionPins()

View file

@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
$composerPopoutZones,
clampPopoutPosition,
getComposerPopoutZone,
POPOUT_WIDTH_REM,
type PopoutBounds,
pruneComposerPopoutZones,
setComposerPopoutPosition,
setComposerPoppedOut
} from './composer-popout'
// jsdom's window is 1024x768; every expectation below is relative to that.
const VW = 1024
const VH = 768
const BOX = { height: 56, width: 320 }
// A split layout: chat on the right half, under a 40px header.
const RIGHT_HALF: PopoutBounds = { bottom: VH, left: VW / 2, right: VW, top: 40 }
// The same drag intent, viewed from the left half.
const LEFT_HALF: PopoutBounds = { bottom: VH, left: 0, right: VW / 2, top: 40 }
const LEFT_ZONE = 'g-left'
const RIGHT_ZONE = 'g-right'
describe('clampPopoutPosition', () => {
it('keeps the whole box inside its surface, not just its corner', () => {
const { bottom, right } = clampPopoutPosition({ bottom: 24, right: 24 }, BOX, RIGHT_HALF)
// Insets are viewport-relative, so convert back to an absolute rect.
const boxRight = VW - right
const boxLeft = boxRight - BOX.width
const boxBottom = VH - bottom
const boxTop = boxBottom - BOX.height
expect(boxLeft).toBeGreaterThanOrEqual(RIGHT_HALF.left)
expect(boxRight).toBeLessThanOrEqual(RIGHT_HALF.right)
expect(boxTop).toBeGreaterThanOrEqual(RIGHT_HALF.top)
expect(boxBottom).toBeLessThanOrEqual(RIGHT_HALF.bottom)
})
it('pulls an out-of-bounds intent back into the surface', () => {
// Dragged far left of this surface (a position that belongs to the other
// half of the split) — it must land inside, not off the edge.
const { right } = clampPopoutPosition({ bottom: 24, right: 900 }, BOX, RIGHT_HALF)
expect(VW - right - BOX.width).toBeGreaterThanOrEqual(RIGHT_HALF.left)
})
it('is pure — the same intent yields a different placement per surface', () => {
const intent = { bottom: 24, right: 24 }
const onRight = clampPopoutPosition(intent, BOX, RIGHT_HALF)
const onLeft = clampPopoutPosition(intent, BOX, LEFT_HALF)
// Right half honors the intent (it fits); left half has to push the box
// inward. Two surfaces, two placements, one unchanged intent — this is what
// lets keep-alive tabs share a zone's position without overwriting it.
expect(onRight).not.toEqual(onLeft)
expect(clampPopoutPosition(intent, BOX, RIGHT_HALF)).toEqual(onRight)
})
it('falls back to the full window when the surface has no measured area', () => {
const { bottom, right } = clampPopoutPosition({ bottom: 24, right: 24 }, BOX, undefined)
expect(bottom).toBe(24)
expect(right).toBe(24)
})
it('assumes the compact float width when the box is unmeasured', () => {
// Pre-layout (peel-off, restore) there is no rect yet; the fallback must
// still leave the box grabbable rather than clamping it to a zero-width slot.
const { right } = clampPopoutPosition({ bottom: 24, right: 5000 }, undefined, RIGHT_HALF)
expect(VW - right - POPOUT_WIDTH_REM * 16).toBeGreaterThanOrEqual(RIGHT_HALF.left)
})
})
describe('pop-out state is scoped to a layout zone', () => {
beforeEach(() => {
$composerPopoutZones.set({})
})
it('floats one zone without touching its neighbor', () => {
setComposerPoppedOut(LEFT_ZONE, true)
// Tabs in the left zone float; the right split stays docked. This is the
// whole point of keying by zone: popping out on the left must not fling a
// composer out of every other pane.
expect(getComposerPopoutZone(LEFT_ZONE).poppedOut).toBe(true)
expect(getComposerPopoutZone(RIGHT_ZONE).poppedOut).toBe(false)
})
it('keeps each zone position independent', () => {
setComposerPopoutPosition(LEFT_ZONE, { bottom: 100, right: 100 })
setComposerPopoutPosition(RIGHT_ZONE, { bottom: 24, right: 24 })
expect(getComposerPopoutZone(LEFT_ZONE).position).toEqual({ bottom: 100, right: 100 })
expect(getComposerPopoutZone(RIGHT_ZONE).position).toEqual({ bottom: 24, right: 24 })
})
it('reports the default for a zone nobody has touched', () => {
// Every tab in a fresh zone reads the same default, so a new split starts
// docked at the default corner rather than inheriting a neighbor's drag.
expect(getComposerPopoutZone('g-unseen')).toEqual({ poppedOut: false, position: { bottom: 24, right: 24 } })
})
it('does not let one zone float seed the zones split after it', () => {
setComposerPoppedOut(LEFT_ZONE, true)
setComposerPopoutPosition(LEFT_ZONE, { bottom: 200, right: 200 })
// A zone created later is its own thing — it must not inherit the float
// state of whichever zone happened to be touched first.
expect(getComposerPopoutZone('g-split-later')).toEqual({
poppedOut: false,
position: { bottom: 24, right: 24 }
})
})
it('drops state for zones that no longer exist', () => {
setComposerPoppedOut(LEFT_ZONE, true)
setComposerPoppedOut(RIGHT_ZONE, true)
pruneComposerPopoutZones([LEFT_ZONE])
expect(Object.keys($composerPopoutZones.get())).toEqual([LEFT_ZONE])
})
})

View file

@ -1,9 +1,13 @@
import { atom } from 'nanostores'
import { atom, computed, type ReadableAtom } from 'nanostores'
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
import { persistString, storedString } from '@/lib/storage'
const POPOUT_ENABLED_STORAGE_KEY = 'hermes.desktop.composerPopout.enabled'
const POPOUT_POSITION_STORAGE_KEY = 'hermes.desktop.composerPopout.position'
const POPOUT_STORAGE_KEY = 'hermes.desktop.composerPopout.zones.v1'
// Pre-zone keys: one flag + one position for the whole window. Read at load to
// seed the first zone the user touches (see `legacySeed`), never written again.
const LEGACY_ENABLED_KEY = 'hermes.desktop.composerPopout.enabled'
const LEGACY_POSITION_KEY = 'hermes.desktop.composerPopout.position'
/** Where the floating composer's bottom-right corner sits, measured as an inset
* from the viewport's bottom/right edges. Anchoring to the bottom-right keeps
@ -14,6 +18,14 @@ export interface PopoutPosition {
right: number
}
/** One layout zone's pop-out state. */
export interface PopoutZoneState {
poppedOut: boolean
/** The user's intended placement for this zone, UNCLAMPED every surface in
* the zone renders `clampPopoutPosition` of it against its own rect. */
position: PopoutPosition
}
// Floating composer width (rem). Shared by the inline style that sets
// --composer-popout-width and the peel-off drag math.
export const POPOUT_WIDTH_REM = 19.5
@ -22,28 +34,86 @@ export const POPOUT_WIDTH_REM = 19.5
// of the window chrome. Matches the brief's "default to the right bottom".
const DEFAULT_POSITION: PopoutPosition = { bottom: 24, right: 24 }
function readPosition(): PopoutPosition {
const raw = storedString(POPOUT_POSITION_STORAGE_KEY)
const DEFAULT_ZONE: PopoutZoneState = { poppedOut: false, position: DEFAULT_POSITION }
if (!raw) {
const isPosition = (value: unknown): value is PopoutPosition => {
const r = value as null | Partial<PopoutPosition>
return typeof r?.bottom === 'number' && typeof r?.right === 'number'
}
/** The pre-zone position, if the user had one. */
function legacyPosition(): PopoutPosition {
try {
const parsed = JSON.parse(storedString(LEGACY_POSITION_KEY) || 'null') as unknown
return isPosition(parsed) ? { bottom: parsed.bottom, right: parsed.right } : DEFAULT_POSITION
} catch {
return DEFAULT_POSITION
}
}
function load(): Record<string, PopoutZoneState> {
const out: Record<string, PopoutZoneState> = {}
try {
const parsed = JSON.parse(raw) as Partial<PopoutPosition>
const parsed = JSON.parse(storedString(POPOUT_STORAGE_KEY) || 'null') as unknown
if (typeof parsed.bottom === 'number' && typeof parsed.right === 'number') {
// Clamp on load — a position persisted on a larger/other monitor must not
// strand the box off-screen on this one.
return clampPosition({ bottom: parsed.bottom, right: parsed.right })
for (const [id, value] of Object.entries((parsed as Record<string, unknown>) ?? {})) {
const zone = value as null | Partial<PopoutZoneState>
if (typeof zone?.poppedOut === 'boolean' && isPosition(zone.position)) {
out[id] = { poppedOut: zone.poppedOut, position: { ...zone.position } }
}
}
} catch {
// Corrupt value — fall back to the default corner.
// Treat unparseable persisted state as missing.
}
return DEFAULT_POSITION
return out
}
/** Every zone's pop-out state, keyed by layout-tree group id.
*
* A GROUP is one stack of tabs, so this is the scope the user experiences:
* tabs in the same zone share a float (switch tabs, the box stays where you
* put it), while a split zone beside them keeps its own popping out on the
* left doesn't fling a composer out of the right. */
export const $composerPopoutZones = atom<Record<string, PopoutZoneState>>(load())
/** Write-through to storage. Called explicitly NOT on every store change: a
* drag updates the position once per frame, and serializing every zone to
* localStorage at 60Hz is exactly the IO the drag path was built to avoid. */
const persistZones = () => persistString(POPOUT_STORAGE_KEY, JSON.stringify($composerPopoutZones.get()))
/** Whether the user had a float before zones existed. Seeds a zone's first read
* so an upgrade doesn't silently dock someone who left their composer floating
* but ONLY until they touch any zone. Once real per-zone state exists, that
* is the truth, and a zone split later starts docked like any other. */
let legacySeed: PopoutZoneState | null =
storedString(LEGACY_ENABLED_KEY) === 'true' ? { poppedOut: true, position: legacyPosition() } : null
const zoneState = (zones: Record<string, PopoutZoneState>, groupId: string): PopoutZoneState =>
zones[groupId] ?? legacySeed ?? DEFAULT_ZONE
// Cached per-zone derived atoms keep useStore subscriptions referentially stable
// (and keep one zone's drag from re-rendering the composers in another).
const zoneCache = new Map<string, ReadableAtom<PopoutZoneState>>()
export function $composerPopoutZone(groupId: string): ReadableAtom<PopoutZoneState> {
let cached = zoneCache.get(groupId)
if (!cached) {
cached = computed($composerPopoutZones, zones => zoneState(zones, groupId))
zoneCache.set(groupId, cached)
}
return cached
}
export const getComposerPopoutZone = (groupId: string): PopoutZoneState =>
zoneState($composerPopoutZones.get(), groupId)
export interface PopoutSize {
height: number
width: number
@ -79,10 +149,17 @@ const clampRange = (value: number, lo: number, hi: number) => Math.min(Math.max(
const rootFontSize = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
/** The chat surface a composer belongs to the element whose rect bounds its
* floating box. Resolved from the composer's OWN surface root, so each mounted
* chat surface (primary, session tile, background tab) gets its own area
* instead of a document-wide first match. */
export const popoutBoundsElement = (composer: Element | null): Element | null =>
(composer?.closest('[data-chat-surface]') ?? document).querySelector('[data-slot="composer-bounds"]')
/** The thread area's viewport rect (excludes a pinned sidebar + the header), or
* undefined before it mounts callers then fall back to the full window. */
export function readPopoutBounds(composer: Element | null): PopoutBounds | undefined {
const el = (composer?.parentElement ?? document).querySelector('[data-slot="composer-bounds"]')
const el = popoutBoundsElement(composer)
if (!el) {
return undefined
@ -95,10 +172,22 @@ export function readPopoutBounds(composer: Element | null): PopoutBounds | undef
return width > 0 && height > 0 ? { bottom, left, right, top } : undefined
}
// Bound the bottom/right inset so the WHOLE box stays inside `area` (the thread
// region, or the window by default) — the corner anchor alone would let the
// box's width/height push it past the opposite edges.
function clampPosition({ bottom, right }: PopoutPosition, size?: PopoutSize, area?: PopoutBounds): PopoutPosition {
/**
* Bound the bottom/right inset so the WHOLE box stays inside `area` (the chat
* surface's region, or the window by default) the corner anchor alone would
* let the box's width/height push it past the opposite edges.
*
* PURE and per-surface: a zone stores the user's INTENT, and each mounted
* composer in it runs that through here against its own rect to get the
* placement it actually renders. Tabs in a zone are keep-alive mounted, so
* clamping into the store instead would have every tab overwrite the others
* with a value bounded by ITS geometry.
*/
export function clampPopoutPosition(
{ bottom, right }: PopoutPosition,
size?: PopoutSize,
area?: PopoutBounds
): PopoutPosition {
const width = size?.width || POPOUT_WIDTH_REM * rootFontSize()
const height = size?.height || MIN_VISIBLE_HEIGHT
const { innerHeight: vh, innerWidth: vw } = window
@ -110,28 +199,57 @@ function clampPosition({ bottom, right }: PopoutPosition, size?: PopoutSize, are
}
}
export const $composerPoppedOut = atom(storedBoolean(POPOUT_ENABLED_STORAGE_KEY, false))
export const $composerPopoutPosition = atom<PopoutPosition>(readPosition())
function patchZone(groupId: string, patch: Partial<PopoutZoneState>) {
const zones = $composerPopoutZones.get()
const stored = zones[groupId]
const next = { ...zoneState(zones, groupId), ...patch }
export function setComposerPoppedOut(value: boolean) {
$composerPoppedOut.set(value)
persistBoolean(POPOUT_ENABLED_STORAGE_KEY, value)
if (
stored?.poppedOut === next.poppedOut &&
stored.position.bottom === next.position.bottom &&
stored.position.right === next.position.right
) {
return
}
// The user has now expressed per-zone intent; the pre-zone value stops
// standing in for zones they haven't touched.
legacySeed = null
$composerPopoutZones.set({ ...zones, [groupId]: next })
}
/** Move the box (state only by default). Used per-frame during a drag no IO
* unless `persist`. Returns the clamped position so callers can sync their live
* ref. Pass the measured `size` for exact bounds; otherwise a fallback keeps it
* on-screen. */
export function setComposerPoppedOut(groupId: string, value: boolean) {
patchZone(groupId, { poppedOut: value })
persistZones()
}
/** Move this zone's box. Used per-frame during a drag, so it only writes the
* in-memory store by default; pass `persist` for the resting position on
* release. Returns the clamped position so callers can sync their live ref. */
export function setComposerPopoutPosition(
groupId: string,
position: PopoutPosition,
{ area, persist, size }: SetPositionOptions = {}
): PopoutPosition {
const next = clampPosition(position, size, area)
$composerPopoutPosition.set(next)
const next = clampPopoutPosition(position, size, area)
patchZone(groupId, { position: next })
if (persist) {
persistString(POPOUT_POSITION_STORAGE_KEY, JSON.stringify(next))
persistZones()
}
return next
}
/** Drop state for zones that no longer exist, so a long-lived install doesn't
* accumulate an entry per zone the user ever split and closed. */
export function pruneComposerPopoutZones(liveGroupIds: Iterable<string>) {
const live = new Set(liveGroupIds)
const zones = $composerPopoutZones.get()
const next = Object.fromEntries(Object.entries(zones).filter(([id]) => live.has(id)))
if (Object.keys(next).length !== Object.keys(zones).length) {
$composerPopoutZones.set(next)
persistZones()
}
}