diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts index 228758dd905..771bf2a9dd3 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -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(null) const lastCompactPillRef = useRef(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') diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts index 518aa3658a5..b326025bb95 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts @@ -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 docked↔floating 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, + 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(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 docked↔floating 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, diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index e1bf600d650..b808b236583 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -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() diff --git a/apps/desktop/src/store/composer-popout.test.ts b/apps/desktop/src/store/composer-popout.test.ts new file mode 100644 index 00000000000..dd551fa4235 --- /dev/null +++ b/apps/desktop/src/store/composer-popout.test.ts @@ -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]) + }) +}) diff --git a/apps/desktop/src/store/composer-popout.ts b/apps/desktop/src/store/composer-popout.ts index 3ac730e5413..a5d3b5ab6d1 100644 --- a/apps/desktop/src/store/composer-popout.ts +++ b/apps/desktop/src/store/composer-popout.ts @@ -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 + + 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 { + const out: Record = {} try { - const parsed = JSON.parse(raw) as Partial + 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) ?? {})) { + const zone = value as null | Partial + + 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>(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, 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>() + +export function $composerPopoutZone(groupId: string): ReadableAtom { + 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(readPosition()) +function patchZone(groupId: string, patch: Partial) { + 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) { + 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() + } +}