Skip to content

Commit d2e20f5

Browse files
committed
feat(webapp): drag floating chat to edges to dock right or fullscreen
1 parent 153c64b commit d2e20f5

5 files changed

Lines changed: 210 additions & 46 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ export function DashboardAgent({
382382
// Tailwind v4's important modifier is a trailing `!`, not a leading one.
383383
className={mode === "rightPanel" ? undefined : "overflow-visible!"}
384384
>
385-
<FloatingAgentWindow mode={mode}>
385+
<FloatingAgentWindow mode={mode} onRequestModeChange={changeMode}>
386386
{({ dragHandleProps, dragHandleClassName }) => (
387387
<DashboardAgentPanel
388388
onClose={() => setPanelOpen(false)}

apps/webapp/app/components/dashboard-agent/panel-layout.dom.test.ts

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ function fakePanInfo(dx: number, dy: number): PanInfo {
8686
};
8787
}
8888

89+
function fakePanInfoAt(point: { x: number; y: number }): PanInfo {
90+
return { delta: { x: 0, y: 0 }, offset: { x: 0, y: 0 }, point, velocity: { x: 0, y: 0 } };
91+
}
92+
8993
describe("the floating window's rect, wired with panel-layout's own constants", () => {
9094
it("renders at initialFloatingRect's position and size", () => {
9195
stubViewport(1200, 900);
@@ -107,19 +111,23 @@ describe("the floating window's rect, wired with panel-layout's own constants",
107111

108112
// Mirrors the real header: a title-like element (draggable) beside a
109113
// `data-agent-no-drag` action (opted out), same as DashboardAgentHeader's button group.
110-
function renderFloatingAgentWindow() {
114+
function renderFloatingAgentWindow(onRequestModeChange?: (mode: DashboardAgentMode) => void) {
111115
let latest!: FloatingDragProps;
112116
function Harness() {
113-
return createElement(FloatingAgentWindow, { mode: "floating" }, (drag: FloatingDragProps) => {
114-
// oxlint-disable-next-line react/globals -- test harness capturing the render-prop's value.
115-
latest = drag;
116-
return createElement(
117-
"div",
118-
null,
119-
createElement("span", { "data-testid": "title" }, "Chat title"),
120-
createElement("button", { "data-agent-no-drag": "", "data-testid": "action" }, "Close")
121-
);
122-
});
117+
return createElement(
118+
FloatingAgentWindow,
119+
{ mode: "floating", onRequestModeChange },
120+
(drag: FloatingDragProps) => {
121+
// oxlint-disable-next-line react/globals -- test harness capturing the render-prop's value.
122+
latest = drag;
123+
return createElement(
124+
"div",
125+
null,
126+
createElement("span", { "data-testid": "title" }, "Chat title"),
127+
createElement("button", { "data-agent-no-drag": "", "data-testid": "action" }, "Close")
128+
);
129+
}
130+
);
123131
}
124132
container = document.createElement("div");
125133
document.body.appendChild(container);
@@ -183,6 +191,82 @@ describe("FloatingAgentWindow's drag-vs-click filter", () => {
183191
});
184192
});
185193

194+
describe("FloatingAgentWindow's drag-to-dock zones", () => {
195+
it("shows the rightPanel hint while dragging near the right edge", () => {
196+
stubViewport(1200, 900);
197+
const view = renderFloatingAgentWindow();
198+
const target = view.titleEl() as unknown as PointerEvent["target"];
199+
200+
act(() => {
201+
view.dragHandleProps.onPanStart!({ target } as PointerEvent, fakePanInfoAt({ x: 0, y: 0 }));
202+
view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfoAt({ x: 1190, y: 400 }));
203+
});
204+
205+
expect(document.body.textContent).toContain("Dock right");
206+
});
207+
208+
it("calls onRequestModeChange with rightPanel on release in the right zone, without a rect jump", () => {
209+
stubViewport(1200, 900);
210+
const onRequestModeChange = vi.fn();
211+
const view = renderFloatingAgentWindow(onRequestModeChange);
212+
const target = view.titleEl() as unknown as PointerEvent["target"];
213+
214+
act(() => {
215+
view.dragHandleProps.onPanStart!({ target } as PointerEvent, fakePanInfoAt({ x: 0, y: 0 }));
216+
view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfoAt({ x: 1190, y: 400 }));
217+
});
218+
const leftBeforeRelease = view.outerLeft();
219+
220+
act(() => {
221+
view.dragHandleProps.onPanEnd!({ target } as PointerEvent, fakePanInfoAt({ x: 1190, y: 400 }));
222+
});
223+
224+
expect(onRequestModeChange).toHaveBeenCalledTimes(1);
225+
expect(onRequestModeChange).toHaveBeenCalledWith("rightPanel");
226+
expect(view.outerLeft()).toBe(leftBeforeRelease);
227+
expect(document.body.textContent).not.toContain("Dock right");
228+
});
229+
230+
it("shows the fullscreen hint and requests fullscreen on release near the top edge", () => {
231+
stubViewport(1200, 900);
232+
const onRequestModeChange = vi.fn();
233+
const view = renderFloatingAgentWindow(onRequestModeChange);
234+
const target = view.titleEl() as unknown as PointerEvent["target"];
235+
236+
act(() => {
237+
view.dragHandleProps.onPanStart!({ target } as PointerEvent, fakePanInfoAt({ x: 0, y: 0 }));
238+
view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfoAt({ x: 500, y: 5 }));
239+
});
240+
expect(document.body.textContent).toContain("Fullscreen");
241+
242+
act(() => {
243+
view.dragHandleProps.onPanEnd!({ target } as PointerEvent, fakePanInfoAt({ x: 500, y: 5 }));
244+
});
245+
246+
expect(onRequestModeChange).toHaveBeenCalledTimes(1);
247+
expect(onRequestModeChange).toHaveBeenCalledWith("fullscreen");
248+
});
249+
250+
it("does not change mode and updates the rect normally on release outside any zone", () => {
251+
stubViewport(1200, 900);
252+
const onRequestModeChange = vi.fn();
253+
const view = renderFloatingAgentWindow(onRequestModeChange);
254+
const target = view.titleEl() as unknown as PointerEvent["target"];
255+
const startLeft = view.outerLeft();
256+
257+
act(() => {
258+
view.dragHandleProps.onPanStart!({ target } as PointerEvent, fakePanInfo(0, 0));
259+
view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfo(-20, 0));
260+
});
261+
act(() => {
262+
view.dragHandleProps.onPanEnd!({ target } as PointerEvent, fakePanInfoAt({ x: 500, y: 400 }));
263+
});
264+
265+
expect(onRequestModeChange).not.toHaveBeenCalled();
266+
expect(view.outerLeft()).not.toBe(startLeft);
267+
});
268+
});
269+
186270
describe("FloatingAgentWindow keeps its child mounted across every mode transition", () => {
187271
it("never remounts the child across any of the three modes (same tree shape always)", () => {
188272
let mounts = 0;

apps/webapp/app/components/dashboard-agent/panel-layout.tsx

Lines changed: 78 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
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.
33
import { useMemo, useRef, useState, type CSSProperties } from "react";
4+
import { createPortal } from "react-dom";
45
import { motion, type PanInfo } from "framer-motion";
56
import {
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";
1117
import { 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
*/
98125
export 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

apps/webapp/app/components/primitives/draggableResizableMath.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,31 @@ import {
66
clampPosition,
77
clampRectToViewport,
88
clampSize,
9+
dockZoneForPoint,
910
resizeRect,
1011
type Rect,
1112
} from "./draggableResizableMath";
1213

14+
describe("dockZoneForPoint", () => {
15+
const viewport = { width: 1200, height: 900 };
16+
17+
it("targets rightPanel near the right edge", () => {
18+
expect(dockZoneForPoint({ x: 1180, y: 400 }, viewport)).toBe("rightPanel");
19+
});
20+
21+
it("targets fullscreen near the top edge", () => {
22+
expect(dockZoneForPoint({ x: 600, y: 10 }, viewport)).toBe("fullscreen");
23+
});
24+
25+
it("prefers rightPanel in the top-right corner", () => {
26+
expect(dockZoneForPoint({ x: 1180, y: 10 }, viewport)).toBe("rightPanel");
27+
});
28+
29+
it("returns null away from every edge", () => {
30+
expect(dockZoneForPoint({ x: 600, y: 400 }, viewport)).toBeNull();
31+
});
32+
});
33+
1334
describe("clamp", () => {
1435
it("clamps to the bounds", () => {
1536
expect(clamp(5, 0, 10)).toBe(5);

apps/webapp/app/components/primitives/draggableResizableMath.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,21 @@ export function applyDragDelta(
103103
return { ...current, ...nextPosition };
104104
}
105105

106+
export type DockZone = "rightPanel" | "fullscreen";
107+
108+
export const DOCK_ZONE_SIZE = 48;
109+
110+
/** Right edge wins the top-right corner, matching which hint the overlay shows there. */
111+
export function dockZoneForPoint(
112+
point: Point,
113+
viewport: Viewport,
114+
zoneSize = DOCK_ZONE_SIZE
115+
): DockZone | null {
116+
if (point.x >= viewport.width - zoneSize) return "rightPanel";
117+
if (point.y <= zoneSize) return "fullscreen";
118+
return null;
119+
}
120+
106121
/** Resize counterpart of {@link applyDragDelta} — same incremental-step rationale. */
107122
export function applyResizeDelta(
108123
edge: ResizeEdge,

0 commit comments

Comments
 (0)