mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
Merge pull request #50466 from NousResearch/bb/composer-popout-bounds
fix(desktop): keep the floating composer in-bounds (can't be lost off-screen)
This commit is contained in:
commit
1ec4fcf614
3 changed files with 133 additions and 41 deletions
|
|
@ -7,8 +7,13 @@ import {
|
|||
useState
|
||||
} from 'react'
|
||||
|
||||
import type { PopoutPosition } from '@/store/composer-popout'
|
||||
import { POPOUT_WIDTH_REM, setComposerPopoutPosition } from '@/store/composer-popout'
|
||||
import {
|
||||
POPOUT_ESTIMATED_HEIGHT,
|
||||
POPOUT_WIDTH_REM,
|
||||
setComposerPopoutPosition,
|
||||
type PopoutPosition,
|
||||
type PopoutSize
|
||||
} from '@/store/composer-popout'
|
||||
|
||||
// Floating surface long-press before it becomes draggable (the 5px platform drags
|
||||
// instantly; this only covers grabbing the composer body itself).
|
||||
|
|
@ -82,6 +87,23 @@ function dockProximityOf(rect: DOMRect) {
|
|||
return v * h
|
||||
}
|
||||
|
||||
const clampOffset = (value: number, max: number) => Math.min(Math.max(0, value), max)
|
||||
|
||||
/** Fixed-position composer uses bottom/right insets; keep the grab point under the pointer. */
|
||||
function popoutPositionUnderPointer(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
grabX: number,
|
||||
grabY: number,
|
||||
boxWidth: number,
|
||||
boxHeight: number
|
||||
): PopoutPosition {
|
||||
return {
|
||||
bottom: window.innerHeight - clientY + grabY - boxHeight,
|
||||
right: window.innerWidth - clientX + grabX - boxWidth
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gesture pop-out / dock for the composer — fully gestural, no hold-to-toggle.
|
||||
*
|
||||
|
|
@ -123,14 +145,15 @@ export function useComposerPopoutGestures({
|
|||
}, [clearTimer])
|
||||
|
||||
const beginFloatDrag = useCallback(
|
||||
(state: PressState, clientX: number, clientY: number, next: PopoutPosition) => {
|
||||
(state: PressState, clientX: number, clientY: number, next: PopoutPosition, size?: PopoutSize) => {
|
||||
clearTimer()
|
||||
liveRef.current = setComposerPopoutPosition(next)
|
||||
const clamped = setComposerPopoutPosition(next, { size })
|
||||
liveRef.current = clamped
|
||||
|
||||
state.mode = 'float'
|
||||
state.armed = true
|
||||
state.startBottom = next.bottom
|
||||
state.startRight = next.right
|
||||
state.startBottom = clamped.bottom
|
||||
state.startRight = clamped.right
|
||||
state.startX = clientX
|
||||
state.startY = clientY
|
||||
|
||||
|
|
@ -147,21 +170,16 @@ export function useComposerPopoutGestures({
|
|||
return
|
||||
}
|
||||
|
||||
// The docked composer is full-width; the floating one is compact. Center it
|
||||
// horizontally on the cursor (the docked grab-X is meaningless at the new
|
||||
// width), but preserve the vertical grab offset so the pointer keeps its
|
||||
// spot (grab the top → stay at the top).
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
|
||||
const rect = composer.getBoundingClientRect()
|
||||
const boxWidth = POPOUT_WIDTH_REM * rem
|
||||
const grabY = Math.min(Math.max(0, state.startY - rect.top), rect.height)
|
||||
const next: PopoutPosition = {
|
||||
bottom: window.innerHeight - (clientY - grabY + rect.height),
|
||||
right: window.innerWidth - clientX - boxWidth / 2
|
||||
}
|
||||
const boxHeight = POPOUT_ESTIMATED_HEIGHT
|
||||
const grabX = clampOffset(state.startX - rect.left, boxWidth)
|
||||
const grabY = clampOffset(state.startY - rect.top, boxHeight)
|
||||
const next = popoutPositionUnderPointer(clientX, clientY, grabX, grabY, boxWidth, boxHeight)
|
||||
|
||||
beginFloatDrag(state, clientX, clientY, next, { height: boxHeight, width: boxWidth })
|
||||
onPopOutRef.current()
|
||||
beginFloatDrag(state, clientX, clientY, next)
|
||||
},
|
||||
[beginFloatDrag, composerRef]
|
||||
)
|
||||
|
|
@ -239,15 +257,19 @@ export function useComposerPopoutGestures({
|
|||
return
|
||||
}
|
||||
|
||||
liveRef.current = setComposerPopoutPosition({
|
||||
bottom: state.startBottom - (pending.y - state.startY),
|
||||
right: state.startRight - (pending.x - state.startX)
|
||||
})
|
||||
const composer = composerRef.current
|
||||
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined
|
||||
|
||||
const rect = composerRef.current?.getBoundingClientRect()
|
||||
liveRef.current = setComposerPopoutPosition(
|
||||
{
|
||||
bottom: state.startBottom - (pending.y - state.startY),
|
||||
right: state.startRight - (pending.x - state.startX)
|
||||
},
|
||||
{ size }
|
||||
)
|
||||
|
||||
if (rect) {
|
||||
setDockProximity(dockProximityOf(rect))
|
||||
if (composer) {
|
||||
setDockProximity(dockProximityOf(composer.getBoundingClientRect()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -297,13 +319,15 @@ export function useComposerPopoutGestures({
|
|||
cancelRaf()
|
||||
|
||||
if (state.armed && state.mode === 'float') {
|
||||
const rect = composerRef.current?.getBoundingClientRect()
|
||||
const composer = composerRef.current
|
||||
const rect = composer?.getBoundingClientRect()
|
||||
|
||||
if (rect && dockProximityOf(rect) >= 1) {
|
||||
onDock()
|
||||
} else {
|
||||
// Persist the resting position once, on release — never per move.
|
||||
setComposerPopoutPosition(liveRef.current, true)
|
||||
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined
|
||||
setComposerPopoutPosition(liveRef.current, { persist: true, size })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,13 @@ import {
|
|||
isBrowsingHistory,
|
||||
resetBrowseState
|
||||
} from '@/store/composer-input-history'
|
||||
import { $composerPopoutPosition, $composerPoppedOut, POPOUT_WIDTH_REM, setComposerPoppedOut } from '@/store/composer-popout'
|
||||
import {
|
||||
$composerPopoutPosition,
|
||||
$composerPoppedOut,
|
||||
POPOUT_WIDTH_REM,
|
||||
setComposerPoppedOut,
|
||||
setComposerPopoutPosition
|
||||
} from '@/store/composer-popout'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
|
|
@ -536,6 +542,27 @@ export function ChatBar({
|
|||
syncComposerMetrics()
|
||||
}, [poppedOut, syncComposerMetrics])
|
||||
|
||||
// Keep the floating box on-screen: re-clamp (with the real measured size) when
|
||||
// it pops out and whenever the window resizes — so a position persisted on a
|
||||
// bigger/other monitor, or a shrunk window, can never strand it out of reach.
|
||||
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(), { persist, size })
|
||||
}
|
||||
|
||||
reclamp(true)
|
||||
const onResize = () => reclamp(false)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [poppedOut])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const root = document.documentElement
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export interface PopoutPosition {
|
|||
}
|
||||
|
||||
// Floating composer width (rem). Shared by the inline style that sets
|
||||
// --composer-popout-width and the peel-off drag math (to center it on the cursor).
|
||||
// --composer-popout-width and the peel-off drag math.
|
||||
export const POPOUT_WIDTH_REM = 19.5
|
||||
|
||||
// Default pop-out placement: tucked into the bottom-right of the thread, clear
|
||||
|
|
@ -33,7 +33,9 @@ function readPosition(): PopoutPosition {
|
|||
const parsed = JSON.parse(raw) as Partial<PopoutPosition>
|
||||
|
||||
if (typeof parsed.bottom === 'number' && typeof parsed.right === 'number') {
|
||||
return { bottom: parsed.bottom, right: parsed.right }
|
||||
// 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 })
|
||||
}
|
||||
} catch {
|
||||
// Corrupt value — fall back to the default corner.
|
||||
|
|
@ -42,6 +44,52 @@ function readPosition(): PopoutPosition {
|
|||
return DEFAULT_POSITION
|
||||
}
|
||||
|
||||
export interface PopoutSize {
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
|
||||
interface SetPositionOptions {
|
||||
persist?: boolean
|
||||
/** Measured box size; falls back to the compact width + a min height so the
|
||||
* box stays grabbable even when the caller can't measure it. */
|
||||
size?: PopoutSize
|
||||
}
|
||||
|
||||
// Keep at least this much of every edge between the box and the viewport, so the
|
||||
// floating composer can never be dragged (or restored) out of reach.
|
||||
const EDGE_MARGIN = 8
|
||||
const TITLEBAR_HEIGHT_FALLBACK = 34
|
||||
const TITLEBAR_CLEARANCE_REM = 0.75
|
||||
// Height floor used when the real box height is unknown (init / load / peel-off).
|
||||
export const POPOUT_ESTIMATED_HEIGHT = 56
|
||||
const MIN_VISIBLE_HEIGHT = POPOUT_ESTIMATED_HEIGHT
|
||||
|
||||
const clampRange = (value: number, lo: number, hi: number) => Math.min(Math.max(value, lo), Math.max(lo, hi))
|
||||
|
||||
const rootFontSize = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
|
||||
|
||||
function titlebarTopMargin() {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue('--titlebar-height').trim()
|
||||
const titlebarHeight = Number.parseFloat(raw)
|
||||
const breathingRoom = TITLEBAR_CLEARANCE_REM * rootFontSize()
|
||||
|
||||
return Math.max(EDGE_MARGIN, (Number.isFinite(titlebarHeight) ? titlebarHeight : TITLEBAR_HEIGHT_FALLBACK) + breathingRoom)
|
||||
}
|
||||
|
||||
// Bound the bottom-right inset so the WHOLE box stays on-screen — the corner
|
||||
// anchor alone would let the box's width/height push it past the left/top edges.
|
||||
function clampPosition({ bottom, right }: PopoutPosition, size?: PopoutSize): PopoutPosition {
|
||||
const width = size?.width || POPOUT_WIDTH_REM * rootFontSize()
|
||||
const height = size?.height || MIN_VISIBLE_HEIGHT
|
||||
const topMargin = titlebarTopMargin()
|
||||
|
||||
return {
|
||||
bottom: clampRange(bottom, EDGE_MARGIN, window.innerHeight - height - topMargin),
|
||||
right: clampRange(right, EDGE_MARGIN, window.innerWidth - width - EDGE_MARGIN)
|
||||
}
|
||||
}
|
||||
|
||||
export const $composerPoppedOut = atom(storedBoolean(POPOUT_ENABLED_STORAGE_KEY, false))
|
||||
export const $composerPopoutPosition = atom<PopoutPosition>(readPosition())
|
||||
|
||||
|
|
@ -50,19 +98,12 @@ export function setComposerPoppedOut(value: boolean) {
|
|||
persistBoolean(POPOUT_ENABLED_STORAGE_KEY, value)
|
||||
}
|
||||
|
||||
const clamp = (value: number, max: number) => Math.min(Math.max(0, value), Math.max(0, max))
|
||||
|
||||
// Clamp the corner inset so a viewport shrink (or a stale persisted value) can't
|
||||
// strand the box fully off-screen.
|
||||
const clampPosition = ({ bottom, right }: PopoutPosition): PopoutPosition => ({
|
||||
bottom: clamp(bottom, window.innerHeight - 60),
|
||||
right: clamp(right, window.innerWidth - 80)
|
||||
})
|
||||
|
||||
/** Move the box (state only). Used per-frame during a drag — no IO. Returns the
|
||||
* clamped position so callers can keep their live ref in sync. */
|
||||
export function setComposerPopoutPosition(position: PopoutPosition, persist = false): PopoutPosition {
|
||||
const next = clampPosition(position)
|
||||
/** 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 setComposerPopoutPosition(position: PopoutPosition, { persist, size }: SetPositionOptions = {}): PopoutPosition {
|
||||
const next = clampPosition(position, size)
|
||||
$composerPopoutPosition.set(next)
|
||||
|
||||
if (persist) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue