mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
Merge pull request #50977 from NousResearch/bb/composer-fixed-portal
fix(desktop): keep floating composer on-screen, scoped to the thread area
This commit is contained in:
commit
7dece1d933
4 changed files with 119 additions and 81 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'
|
||||
|
|
@ -542,9 +543,12 @@ 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.
|
||||
// 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
|
||||
|
|
@ -553,14 +557,18 @@ 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)
|
||||
const raf = requestAnimationFrame(() => reclamp(true))
|
||||
const onResize = () => reclamp(false)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', onResize)
|
||||
}
|
||||
}, [poppedOut])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -433,17 +433,18 @@ export function ChatView({
|
|||
|
||||
<PromptOverlays />
|
||||
|
||||
<div
|
||||
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
|
||||
{...dropHandlers}
|
||||
<ChatRuntimeBoundary
|
||||
busy={busy}
|
||||
onCancel={onCancel}
|
||||
onEdit={onEdit}
|
||||
onReload={onReload}
|
||||
onThreadMessagesChange={onThreadMessagesChange}
|
||||
suppressMessages={routeSessionMismatch}
|
||||
>
|
||||
<ChatRuntimeBoundary
|
||||
busy={busy}
|
||||
onCancel={onCancel}
|
||||
onEdit={onEdit}
|
||||
onReload={onReload}
|
||||
onThreadMessagesChange={onThreadMessagesChange}
|
||||
suppressMessages={routeSessionMismatch}
|
||||
<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
|
||||
clampToComposer={showChatBar}
|
||||
|
|
@ -458,54 +459,62 @@ export function ChatView({
|
|||
sessionId={activeSessionId}
|
||||
sessionKey={threadKey}
|
||||
/>
|
||||
{showChatBar && (
|
||||
<Suspense fallback={<ChatBarFallback />}>
|
||||
<ChatBar
|
||||
busy={busy}
|
||||
cwd={currentCwd}
|
||||
disabled={!gatewayOpen}
|
||||
focusKey={activeSessionId}
|
||||
gateway={gateway}
|
||||
maxRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
onAddContextRef={onAddContextRef}
|
||||
onAddUrl={onAddUrl}
|
||||
onAttachDroppedItems={onAttachDroppedItems}
|
||||
onAttachImageBlob={onAttachImageBlob}
|
||||
onCancel={onCancel}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSteer={onSteer}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
queueSessionKey={selectedSessionId}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
{resumeExhausted && routedSessionId && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
|
||||
<ErrorState
|
||||
className="max-w-sm"
|
||||
description={t.desktop.resumeStrandedBody}
|
||||
title={t.desktop.resumeStrandedTitle}
|
||||
>
|
||||
<div className="grid justify-items-center">
|
||||
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
|
||||
{t.desktop.resumeRetry}
|
||||
</Button>
|
||||
</div>
|
||||
</ErrorState>
|
||||
</div>
|
||||
)}
|
||||
</ChatRuntimeBoundary>
|
||||
{resumeExhausted && routedSessionId && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
|
||||
<ErrorState
|
||||
className="max-w-sm"
|
||||
description={t.desktop.resumeStrandedBody}
|
||||
title={t.desktop.resumeStrandedTitle}
|
||||
>
|
||||
<div className="grid justify-items-center">
|
||||
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
|
||||
{t.desktop.resumeRetry}
|
||||
</Button>
|
||||
</div>
|
||||
</ErrorState>
|
||||
</div>
|
||||
{showChatBar && <ScrollToBottomButton />}
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
{/* Composer renders OUTSIDE the contain:[layout paint] wrapper above:
|
||||
that wrapper is a containing block for — and clips — position:fixed
|
||||
descendants, so the popped-out (fixed) composer would anchor to the
|
||||
chat column (which shifts/resizes with the sidebars) and get clipped
|
||||
off-screen instead of floating against the viewport. As a sibling it
|
||||
anchors to the outer relative container instead: docked is absolute
|
||||
(identical placement), floating resolves against the viewport. Both
|
||||
states stay mounted here, so dock⇄float never remounts the editor. */}
|
||||
{showChatBar && (
|
||||
<Suspense fallback={<ChatBarFallback />}>
|
||||
<ChatBar
|
||||
busy={busy}
|
||||
cwd={currentCwd}
|
||||
disabled={!gatewayOpen}
|
||||
focusKey={activeSessionId}
|
||||
gateway={gateway}
|
||||
maxRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
onAddContextRef={onAddContextRef}
|
||||
onAddUrl={onAddUrl}
|
||||
onAttachDroppedItems={onAttachDroppedItems}
|
||||
onAttachImageBlob={onAttachImageBlob}
|
||||
onCancel={onCancel}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSteer={onSteer}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
queueSessionKey={selectedSessionId}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{showChatBar && <ScrollToBottomButton />}
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
</ChatRuntimeBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,34 @@ 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, height, left, right, top, width } = el.getBoundingClientRect()
|
||||
|
||||
// Pre-layout (mount before first layout) the rect is empty — fall back to the
|
||||
// window rather than clamping the box into a collapsed area.
|
||||
return width > 0 && height > 0 ? { bottom, left, right, top } : undefined
|
||||
}
|
||||
|
||||
// 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 +122,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