11// Both class helpers apply to always-rendered wrappers, so switching display mode is a
22// class change only and the open chat's transport, session and transcript survive it.
33import { useMemo , useRef , useState , type CSSProperties } from "react" ;
4+ import { createPortal } from "react-dom" ;
45import { motion , type PanInfo } from "framer-motion" ;
56import {
67 draggableResizeHandleClassName ,
78 useDraggableResizable ,
89 type PanHandlerProps ,
910 type ResizeEdge ,
1011} from "~/components/primitives/DraggableResizable" ;
12+ import {
13+ dockZoneForPoint ,
14+ type DockZone ,
15+ type Point ,
16+ } from "~/components/primitives/draggableResizableMath" ;
1117import { cn } from "~/utils/cn" ;
1218
1319// Mark an element (e.g. a header button, or just its icon) with `data-agent-no-drag` so a
@@ -89,6 +95,27 @@ export function agentHiddenContentClassName(fullscreen: boolean): string {
8995 return cn ( "h-full overflow-hidden" , fullscreen && "invisible" ) ;
9096}
9197
98+ const DOCK_ZONE_LABEL : Record < DockZone , string > = {
99+ rightPanel : "Dock right" ,
100+ fullscreen : "Fullscreen" ,
101+ } ;
102+
103+ /** Transparent hint over the drop target, portaled so panel overflow can't clip it. */
104+ function DockZoneOverlay ( { zone } : { zone : DockZone } ) {
105+ if ( typeof document === "undefined" ) return null ;
106+ return createPortal (
107+ < div
108+ className = { cn (
109+ "pointer-events-none fixed z-50 flex items-center justify-center border-2 border-dashed border-text-link bg-background-dimmed/70 text-sm text-text-bright backdrop-blur-xs" ,
110+ zone === "rightPanel" ? "inset-y-0 right-0 w-[380px]" : "inset-0"
111+ ) }
112+ >
113+ { DOCK_ZONE_LABEL [ zone ] }
114+ </ div > ,
115+ document . body
116+ ) ;
117+ }
118+
92119/**
93120 * Owns the drag-vs-click filter, so the panel and the standalone story behave identically.
94121 * Fullscreen needs a `relative` ancestor for `agentTakeoverClassName`, supplied by the caller.
@@ -97,9 +124,11 @@ export function agentHiddenContentClassName(fullscreen: boolean): string {
97124 */
98125export function FloatingAgentWindow ( {
99126 mode,
127+ onRequestModeChange,
100128 children,
101129} : {
102130 mode : DashboardAgentMode ;
131+ onRequestModeChange ?: ( mode : DashboardAgentMode ) => void ;
103132 children : ( drag : FloatingDragProps ) => React . ReactNode ;
104133} ) {
105134 const fullscreen = mode === "fullscreen" ;
@@ -111,6 +140,7 @@ export function FloatingAgentWindow({
111140 viewportPadding : FLOATING_MARGIN ,
112141 } ) ;
113142 const [ dragging , setDragging ] = useState ( false ) ;
143+ const [ dockZone , setDockZone ] = useState < DockZone | null > ( null ) ;
114144 // onPan can arrive before onPanStart, so the no-drag check runs once, on whichever fires first.
115145 const gestureClassified = useRef ( false ) ;
116146 const ignoringGesture = useRef ( false ) ;
@@ -121,6 +151,9 @@ export function FloatingAgentWindow({
121151 ignoringGesture . current = ! ! ( event . target as HTMLElement | null ) ?. closest ( NO_DRAG_SELECTOR ) ;
122152 } ;
123153
154+ const zoneForPoint = ( point : Point ) =>
155+ dockZoneForPoint ( point , { width : window . innerWidth , height : window . innerHeight } ) ;
156+
124157 // Same shape as `dragHandleProps` below empty, so a mode with no drag doesn't change types.
125158 const filteredDragHandleProps : Partial < PanHandlerProps > =
126159 fullscreen || docked
@@ -135,56 +168,67 @@ export function FloatingAgentWindow({
135168 onPan : ( event : PointerEvent , info : PanInfo ) => {
136169 classifyGesture ( event ) ;
137170 if ( ignoringGesture . current ) return ;
171+ setDockZone ( zoneForPoint ( info . point ) ) ;
138172 dragHandleProps . onPan ?.( event , info ) ;
139173 } ,
140174 onPanEnd : ( event : PointerEvent , info : PanInfo ) => {
175+ const wasIgnoring = ignoringGesture . current ;
141176 gestureClassified . current = false ;
142177 ignoringGesture . current = false ;
143178 setDragging ( false ) ;
179+ setDockZone ( null ) ;
180+ const droppedZone = wasIgnoring ? null : zoneForPoint ( info . point ) ;
181+ if ( droppedZone ) {
182+ onRequestModeChange ?.( droppedZone ) ;
183+ return ;
184+ }
144185 dragHandleProps . onPanEnd ?.( event , info ) ;
145186 } ,
146187 } ;
147188
148189 // Same two-`div` shape in all three modes — only classes/style change — so switching `mode`
149190 // never unmounts `children`; only className/style differ.
150191 return (
151- < div
152- style = { fullscreen || docked ? CLEARED_FLOATING_STYLE : style }
153- className = {
154- fullscreen
155- ? agentTakeoverClassName ( true )
156- : docked
157- ? "flex h-full flex-col"
158- : "z-30 flex flex-col rounded-lg border border-border-bright bg-background-bright shadow-2xl"
159- }
160- >
161- { /* Clips content to the rounded corners without clipping the resize handles below,
162- which sit half outside this box's edges. */ }
192+ < >
193+ { dragging && dockZone && < DockZoneOverlay zone = { dockZone } /> }
163194 < div
164- className = { cn (
165- "flex min-h-0 flex-1 flex-col" ,
166- ! fullscreen && ! docked && "overflow-hidden rounded-lg"
167- ) }
195+ style = { fullscreen || docked ? CLEARED_FLOATING_STYLE : style }
196+ className = {
197+ fullscreen
198+ ? agentTakeoverClassName ( true )
199+ : docked
200+ ? "flex h-full flex-col"
201+ : "z-30 flex flex-col rounded-lg border border-border-bright bg-background-bright shadow-2xl"
202+ }
168203 >
169- { /* oxlint-disable-next-line react/refs -- the ref is only read inside event handlers, not during render. */ }
170- { children ( {
171- dragHandleProps : filteredDragHandleProps ,
172- dragHandleClassName :
173- fullscreen || docked
174- ? ""
175- : cn ( "select-none touch-none" , dragging ? "cursor-grabbing" : "cursor-grab" ) ,
176- } ) }
204+ { /* Clips content to the rounded corners without clipping the resize handles below,
205+ which sit half outside this box's edges. */ }
206+ < div
207+ className = { cn (
208+ "flex min-h-0 flex-1 flex-col" ,
209+ ! fullscreen && ! docked && "overflow-hidden rounded-lg"
210+ ) }
211+ >
212+ { /* oxlint-disable-next-line react/refs -- the ref is only read inside event handlers, not during render. */ }
213+ { children ( {
214+ dragHandleProps : filteredDragHandleProps ,
215+ dragHandleClassName :
216+ fullscreen || docked
217+ ? ""
218+ : cn ( "select-none touch-none" , dragging ? "cursor-grabbing" : "cursor-grab" ) ,
219+ } ) }
220+ </ div >
221+ { ! fullscreen &&
222+ ! docked &&
223+ RESIZE_EDGES . map ( ( edge ) => (
224+ < motion . div
225+ key = { edge }
226+ { ...resizeHandleProps ( edge ) }
227+ className = { draggableResizeHandleClassName ( edge ) }
228+ />
229+ ) ) }
177230 </ div >
178- { ! fullscreen &&
179- ! docked &&
180- RESIZE_EDGES . map ( ( edge ) => (
181- < motion . div
182- key = { edge }
183- { ...resizeHandleProps ( edge ) }
184- className = { draggableResizeHandleClassName ( edge ) }
185- />
186- ) ) }
187- </ div >
231+ </ >
188232 ) ;
189233}
190234
0 commit comments