mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
25ff775136
commit
df25d77131
5 changed files with 416 additions and 87 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<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 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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
130
apps/desktop/src/store/composer-popout.test.ts
Normal file
130
apps/desktop/src/store/composer-popout.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue