Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 51 additions & 27 deletions apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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

Expand All @@ -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]
)
Expand Down Expand Up @@ -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 rect = composerRef.current?.getBoundingClientRect()

if (rect) {
setDockProximity(dockProximityOf(rect))
const composer = composerRef.current
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined

liveRef.current = setComposerPopoutPosition(
{
bottom: state.startBottom - (pending.y - state.startY),
right: state.startRight - (pending.x - state.startX)
},
{ size }
)

if (composer) {
setDockProximity(dockProximityOf(composer.getBoundingClientRect()))
}
}

Expand Down Expand Up @@ -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 })
}
}

Expand Down
29 changes: 28 additions & 1 deletion apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
71 changes: 56 additions & 15 deletions apps/desktop/src/store/composer-popout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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())

Expand All @@ -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) {
Expand Down
Loading