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) {