diff --git a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts index 1c6f99320ac..38feb50d9ae 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts @@ -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 }) } } diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 44ad0fa2a39..ae175c902eb 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 10421d3d91f..2b6586cf5a1 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -443,6 +443,7 @@ export function ChatView({ >
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) {