mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
fix(desktop): clamp floating composer to the thread area, not the whole window
Now that the popped-out composer is fixed to the viewport, clamping against the window let it slide under a pinned sidebar. Confine it to the thread region (data-slot="composer-bounds") instead — its rect already excludes a pinned sidebar and the header — falling back to the full window before it's measured. This subsumes the old titlebar top-margin (the thread rect starts below the header).
This commit is contained in:
parent
aff5ae692f
commit
ea5fa505d9
4 changed files with 42 additions and 21 deletions
|
|
@ -10,6 +10,7 @@ import {
|
|||
import {
|
||||
POPOUT_ESTIMATED_HEIGHT,
|
||||
POPOUT_WIDTH_REM,
|
||||
readPopoutBounds,
|
||||
setComposerPopoutPosition,
|
||||
type PopoutPosition,
|
||||
type PopoutSize
|
||||
|
|
@ -147,7 +148,7 @@ export function useComposerPopoutGestures({
|
|||
const beginFloatDrag = useCallback(
|
||||
(state: PressState, clientX: number, clientY: number, next: PopoutPosition, size?: PopoutSize) => {
|
||||
clearTimer()
|
||||
const clamped = setComposerPopoutPosition(next, { size })
|
||||
const clamped = setComposerPopoutPosition(next, { area: readPopoutBounds(composerRef.current), size })
|
||||
liveRef.current = clamped
|
||||
|
||||
state.mode = 'float'
|
||||
|
|
@ -159,7 +160,7 @@ export function useComposerPopoutGestures({
|
|||
|
||||
setDragging(true)
|
||||
},
|
||||
[clearTimer]
|
||||
[clearTimer, composerRef]
|
||||
)
|
||||
|
||||
const peelOffFromDock = useCallback(
|
||||
|
|
@ -265,7 +266,7 @@ export function useComposerPopoutGestures({
|
|||
bottom: state.startBottom - (pending.y - state.startY),
|
||||
right: state.startRight - (pending.x - state.startX)
|
||||
},
|
||||
{ size }
|
||||
{ area: readPopoutBounds(composer), size }
|
||||
)
|
||||
|
||||
if (composer) {
|
||||
|
|
@ -327,7 +328,7 @@ export function useComposerPopoutGestures({
|
|||
} else {
|
||||
// Persist the resting position once, on release — never per move.
|
||||
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined
|
||||
setComposerPopoutPosition(liveRef.current, { persist: true, size })
|
||||
setComposerPopoutPosition(liveRef.current, { area: readPopoutBounds(composer), persist: true, size })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import {
|
|||
$composerPopoutPosition,
|
||||
$composerPoppedOut,
|
||||
POPOUT_WIDTH_REM,
|
||||
readPopoutBounds,
|
||||
setComposerPoppedOut,
|
||||
setComposerPopoutPosition
|
||||
} from '@/store/composer-popout'
|
||||
|
|
@ -553,7 +554,7 @@ export function ChatBar({
|
|||
const reclamp = (persist: boolean) => {
|
||||
const el = composerRef.current
|
||||
const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined
|
||||
setComposerPopoutPosition($composerPopoutPosition.get(), { persist, size })
|
||||
setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size })
|
||||
}
|
||||
|
||||
reclamp(true)
|
||||
|
|
|
|||
|
|
@ -443,6 +443,7 @@ export function ChatView({
|
|||
>
|
||||
<div
|
||||
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
|
||||
data-slot="composer-bounds"
|
||||
{...dropHandlers}
|
||||
>
|
||||
<Thread
|
||||
|
|
|
|||
|
|
@ -49,18 +49,28 @@ export interface PopoutSize {
|
|||
width: number
|
||||
}
|
||||
|
||||
/** Viewport-space rect the floating composer is confined to. Defaults to the
|
||||
* whole window; pass the thread area so the box can't slide under a pinned
|
||||
* sidebar or behind the header. */
|
||||
export interface PopoutBounds {
|
||||
bottom: number
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
}
|
||||
|
||||
interface SetPositionOptions {
|
||||
/** Thread-area rect to confine the box to; falls back to the full window. */
|
||||
area?: PopoutBounds
|
||||
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
|
||||
// Keep at least this much between the box and every edge of its bounds, 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
|
||||
|
|
@ -69,24 +79,32 @@ const clampRange = (value: number, lo: number, hi: number) => Math.min(Math.max(
|
|||
|
||||
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()
|
||||
/** 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"]')
|
||||
|
||||
return Math.max(EDGE_MARGIN, (Number.isFinite(titlebarHeight) ? titlebarHeight : TITLEBAR_HEIGHT_FALLBACK) + breathingRoom)
|
||||
if (!el) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { bottom, left, right, top } = el.getBoundingClientRect()
|
||||
|
||||
return { bottom, left, right, top }
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
const width = size?.width || POPOUT_WIDTH_REM * rootFontSize()
|
||||
const height = size?.height || MIN_VISIBLE_HEIGHT
|
||||
const topMargin = titlebarTopMargin()
|
||||
const { innerHeight: vh, innerWidth: vw } = window
|
||||
const a = area ?? { bottom: vh, left: 0, right: vw, top: 0 }
|
||||
|
||||
return {
|
||||
bottom: clampRange(bottom, EDGE_MARGIN, window.innerHeight - height - topMargin),
|
||||
right: clampRange(right, EDGE_MARGIN, window.innerWidth - width - EDGE_MARGIN)
|
||||
bottom: clampRange(bottom, vh - a.bottom + EDGE_MARGIN, vh - a.top - height - EDGE_MARGIN),
|
||||
right: clampRange(right, vw - a.right + EDGE_MARGIN, vw - a.left - width - EDGE_MARGIN)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -102,8 +120,8 @@ export function setComposerPoppedOut(value: boolean) {
|
|||
* 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)
|
||||
export function setComposerPopoutPosition(position: PopoutPosition, { area, persist, size }: SetPositionOptions = {}): PopoutPosition {
|
||||
const next = clampPosition(position, size, area)
|
||||
$composerPopoutPosition.set(next)
|
||||
|
||||
if (persist) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue