Skip to content

Commit 8ce45c3

Browse files
committed
fix(webapp): drag filter opts out by marker, not tag, and lives in the shell
closest("button, a, input, [role=button]") rejected pans starting on the header title, since the whole title (including the truncated text) sits inside a Popover trigger <button>. Replace with an opt-out marker (data-agent-no-drag) on the header's action-button group and on the popover trigger's chevron only, so the title text stays draggable while Radix's click-to-open behavior is untouched. Move the filter and cursor/touch-action state out of DashboardAgentPanel and into FloatingAgentWindow itself, so every consumer (the real panel, the standalone story) gets identical behavior instead of reimplementing it. New tests in panel-layout.dom.test.ts drive the filter directly: a pan starting on ordinary content drags, one starting on a data-agent-no-drag element doesn't.
1 parent 1447674 commit 8ce45c3

7 files changed

Lines changed: 136 additions & 62 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ export function DashboardAgent({
346346
<div className="relative h-full min-h-0">
347347
<div className={agentHiddenContentClassName(fullscreen)}>{children}</div>
348348
<FloatingAgentWindow fullscreen={fullscreen}>
349-
{(dragHandleProps) => (
349+
{({ dragHandleProps, dragHandleClassName }) => (
350350
<DashboardAgentPanel
351351
onClose={() => setPanelOpen(false)}
352352
requestedMessage={requestedMessage}
@@ -361,6 +361,7 @@ export function DashboardAgent({
361361
isFullscreen={fullscreen}
362362
onToggleFullscreen={toggleFullscreen}
363363
dragHandleProps={dragHandleProps}
364+
dragHandleClassName={dragHandleClassName}
364365
/>
365366
)}
366367
</FloatingAgentWindow>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export function DashboardAgentHeader({
103103
onConfirm={onDeleteChat}
104104
/>
105105

106-
<div className="flex shrink-0 items-center gap-0.5">
106+
<div className="flex shrink-0 items-center gap-0.5" data-agent-no-drag>
107107
{showNewChat && (
108108
<Button
109109
variant="minimal/small"

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

Lines changed: 4 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ import {
5353
import { AgentPanelColumn } from "./panel-layout";
5454
import { markerAfterActiveChat, markerAfterActivity } from "./thinking-marker";
5555
import { concurrencyPath } from "~/utils/pathBuilder";
56-
import { cn } from "~/utils/cn";
5756

5857
function serializePageContext(pageContext: AgentPageContext): string | undefined {
5958
try {
@@ -88,12 +87,14 @@ export function DashboardAgentPanel({
8887
isFullscreen = false,
8988
onToggleFullscreen,
9089
dragHandleProps,
90+
dragHandleClassName,
9191
}: {
9292
onClose: () => void;
9393
isFullscreen?: boolean;
9494
onToggleFullscreen?: () => void;
95-
/** Spread onto the header, which is the floating window's drag handle. */
95+
/** Spread onto the header, which is the floating window's drag handle; already filtered by `FloatingAgentWindow`. */
9696
dragHandleProps?: Partial<PanHandlerProps>;
97+
dragHandleClassName?: string;
9798
// Every `seq` below distinguishes repeat requests with identical contents.
9899
requestedMessage?: { text: string; seq: number };
99100
openChatRequest?: { chatId: string; seq: number };
@@ -133,13 +134,6 @@ export function DashboardAgentPanel({
133134
const [loading, setLoading] = useState(
134135
() => readLastChat(storageKey)?.path === location.pathname
135136
);
136-
// Cursor feedback only; the drag itself is handled by `dragHandleProps`.
137-
const [draggingWindow, setDraggingWindow] = useState(false);
138-
// Fullscreen passes no drag handlers at all (an empty object), so pan wiring is a no-op there.
139-
const isDraggable = !!dragHandleProps?.onPan;
140-
// Set when a gesture starts on a header button/link, so its onPan steps are dropped too.
141-
const ignoringGesture = useRef(false);
142-
143137
const currentPage = agentPageLabel(pageContext, location.pathname);
144138

145139
const pagePaths = useMemo<Record<string, string>>(
@@ -616,48 +610,7 @@ export function DashboardAgentPanel({
616610
onClose();
617611
}}
618612
>
619-
<motion.div
620-
{...dragHandleProps}
621-
onPanStart={
622-
isDraggable
623-
? (event, info) => {
624-
// Buttons/links inside the header (history, new chat, expand, close) sit
625-
// above the drag handle; a click there must not move the window.
626-
if (
627-
(event.target as HTMLElement | null)?.closest("button, a, input, [role=button]")
628-
) {
629-
ignoringGesture.current = true;
630-
return;
631-
}
632-
ignoringGesture.current = false;
633-
setDraggingWindow(true);
634-
dragHandleProps?.onPanStart?.(event, info);
635-
}
636-
: undefined
637-
}
638-
onPan={
639-
isDraggable
640-
? (event, info) => {
641-
if (ignoringGesture.current) return;
642-
dragHandleProps?.onPan?.(event, info);
643-
}
644-
: undefined
645-
}
646-
onPanEnd={
647-
isDraggable
648-
? (event, info) => {
649-
ignoringGesture.current = false;
650-
setDraggingWindow(false);
651-
dragHandleProps?.onPanEnd?.(event, info);
652-
}
653-
: undefined
654-
}
655-
className={cn(
656-
"select-none",
657-
isDraggable && "touch-none",
658-
isDraggable && (draggingWindow ? "cursor-grabbing" : "cursor-grab")
659-
)}
660-
>
613+
<motion.div {...dragHandleProps} className={dragHandleClassName}>
661614
<DashboardAgentHeader
662615
title={headerTitle}
663616
chats={chats}

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
FLOATING_MARGIN,
1111
FLOATING_MIN_SIZE,
1212
FLOATING_WIDTH,
13+
FloatingAgentWindow,
1314
initialFloatingRect,
15+
type FloatingDragProps,
1416
} from "./panel-layout";
1517

1618
let container: HTMLDivElement | undefined;
@@ -95,3 +97,65 @@ describe("the floating window's rect, wired with panel-layout's own constants",
9597
expect(hook.current.size.w).toBe(FLOATING_MIN_SIZE.w);
9698
});
9799
});
100+
101+
// Mirrors the real header: a title-like element (draggable) beside a
102+
// `data-agent-no-drag` action (opted out), same as DashboardAgentHeader's button group.
103+
function renderFloatingAgentWindow() {
104+
let latest!: FloatingDragProps;
105+
function Harness() {
106+
return createElement(FloatingAgentWindow, { fullscreen: false }, (drag: FloatingDragProps) => {
107+
// oxlint-disable-next-line react/globals -- test harness capturing the render-prop's value.
108+
latest = drag;
109+
return createElement(
110+
"div",
111+
null,
112+
createElement("span", { "data-testid": "title" }, "Chat title"),
113+
createElement("button", { "data-agent-no-drag": "", "data-testid": "action" }, "Close")
114+
);
115+
});
116+
}
117+
container = document.createElement("div");
118+
document.body.appendChild(container);
119+
root = createRoot(container);
120+
act(() => {
121+
root!.render(createElement(Harness));
122+
});
123+
return {
124+
get dragHandleProps() {
125+
return latest.dragHandleProps;
126+
},
127+
outerLeft: () => (container!.firstElementChild as HTMLDivElement).style.left,
128+
titleEl: () => container!.querySelector<HTMLElement>('[data-testid="title"]')!,
129+
actionEl: () => container!.querySelector<HTMLElement>('[data-testid="action"]')!,
130+
};
131+
}
132+
133+
describe("FloatingAgentWindow's drag-vs-click filter", () => {
134+
it("drags when a gesture starts on ordinary content, like the header title", () => {
135+
stubViewport(1200, 900);
136+
const view = renderFloatingAgentWindow();
137+
const startLeft = view.outerLeft();
138+
139+
act(() => {
140+
const target = view.titleEl() as unknown as PointerEvent["target"];
141+
view.dragHandleProps.onPanStart!({ target } as PointerEvent, fakePanInfo(0, 0));
142+
view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfo(-20, 0));
143+
});
144+
145+
expect(view.outerLeft()).not.toBe(startLeft);
146+
});
147+
148+
it("does not drag when a gesture starts on a data-agent-no-drag element", () => {
149+
stubViewport(1200, 900);
150+
const view = renderFloatingAgentWindow();
151+
const startLeft = view.outerLeft();
152+
153+
act(() => {
154+
const target = view.actionEl() as unknown as PointerEvent["target"];
155+
view.dragHandleProps.onPanStart!({ target } as PointerEvent, fakePanInfo(0, 0));
156+
view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfo(-20, 0));
157+
});
158+
159+
expect(view.outerLeft()).toBe(startLeft);
160+
});
161+
});

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

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Both class helpers apply to always-rendered wrappers, so toggling fullscreen is a
22
// class change only and the open chat's transport, session and transcript survive it.
3-
import { useMemo } from "react";
4-
import { motion } from "framer-motion";
3+
import { useMemo, useRef, useState } from "react";
4+
import { motion, type PanInfo } from "framer-motion";
55
import {
66
draggableResizeHandleClassName,
77
useDraggableResizable,
@@ -10,6 +10,16 @@ import {
1010
} from "~/components/primitives/DraggableResizable";
1111
import { cn } from "~/utils/cn";
1212

13+
// Mark an element (e.g. a header button, or just its icon) with `data-agent-no-drag` so a
14+
// pan starting on it never drags the window.
15+
const NO_DRAG_SELECTOR = "[data-agent-no-drag]";
16+
17+
/** Spread onto the drag handle; `dragHandleClassName` already carries cursor/touch-action/select-none. */
18+
export type FloatingDragProps = {
19+
dragHandleProps: Partial<PanHandlerProps>;
20+
dragHandleClassName: string;
21+
};
22+
1323
const AGENT_FULLSCREEN_STORAGE_KEY = "tdev:dashboard-agent:fullscreen";
1424

1525
// V1 floating window: 380x512, bottom-right, matching the gallery's own panel frame.
@@ -59,25 +69,59 @@ export function agentHiddenContentClassName(fullscreen: boolean): string {
5969
return cn("h-full overflow-hidden", fullscreen && "invisible");
6070
}
6171

62-
/** Fullscreen needs a `relative` ancestor for `agentTakeoverClassName`; the caller (`DashboardAgent`) supplies it. */
72+
/**
73+
* Fullscreen needs a `relative` ancestor for `agentTakeoverClassName`; the caller
74+
* (`DashboardAgent`) supplies it. Owns the drag-vs-click filter so every consumer (the real
75+
* panel, the standalone story) gets identical behavior: a pan starting on a
76+
* `data-agent-no-drag` element (or a descendant of one) never moves the window.
77+
*/
6378
export function FloatingAgentWindow({
6479
fullscreen,
6580
children,
6681
}: {
6782
fullscreen: boolean;
68-
children: (dragHandleProps: Partial<PanHandlerProps>) => React.ReactNode;
83+
children: (drag: FloatingDragProps) => React.ReactNode;
6984
}) {
7085
const initial = useMemo(() => initialFloatingRect(), []);
7186
const { style, dragHandleProps, resizeHandleProps } = useDraggableResizable({
7287
initial,
7388
minSize: FLOATING_MIN_SIZE,
7489
viewportPadding: FLOATING_MARGIN,
7590
});
91+
const [dragging, setDragging] = useState(false);
92+
// Set for the rest of a gesture that started on a no-drag element, since framer-motion
93+
// can deliver onPan before onPanStart and the target is only known at start.
94+
const ignoringGesture = useRef(false);
7695

7796
if (fullscreen) {
78-
return <div className={agentTakeoverClassName(true)}>{children({})}</div>;
97+
return (
98+
<div className={agentTakeoverClassName(true)}>
99+
{children({ dragHandleProps: {}, dragHandleClassName: "" })}
100+
</div>
101+
);
79102
}
80103

104+
const filteredDragHandleProps: Partial<PanHandlerProps> = {
105+
onPanStart: (event: PointerEvent, info: PanInfo) => {
106+
if ((event.target as HTMLElement | null)?.closest(NO_DRAG_SELECTOR)) {
107+
ignoringGesture.current = true;
108+
return;
109+
}
110+
ignoringGesture.current = false;
111+
setDragging(true);
112+
dragHandleProps.onPanStart?.(event, info);
113+
},
114+
onPan: (event: PointerEvent, info: PanInfo) => {
115+
if (ignoringGesture.current) return;
116+
dragHandleProps.onPan?.(event, info);
117+
},
118+
onPanEnd: (event: PointerEvent, info: PanInfo) => {
119+
ignoringGesture.current = false;
120+
setDragging(false);
121+
dragHandleProps.onPanEnd?.(event, info);
122+
},
123+
};
124+
81125
return (
82126
<div
83127
style={style}
@@ -86,7 +130,14 @@ export function FloatingAgentWindow({
86130
{/* Clips content to the rounded corners without clipping the resize handles below,
87131
which sit half outside this box's edges. */}
88132
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg">
89-
{children(dragHandleProps)}
133+
{/* oxlint-disable-next-line react/refs -- filteredDragHandleProps' closures only touch the ref inside their own event handlers, not during this render. */}
134+
{children({
135+
dragHandleProps: filteredDragHandleProps,
136+
dragHandleClassName: cn(
137+
"select-none touch-none",
138+
dragging ? "cursor-grabbing" : "cursor-grab"
139+
),
140+
})}
90141
</div>
91142
{RESIZE_EDGES.map((edge) => (
92143
<motion.div

apps/webapp/app/components/primitives/Popover.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,12 @@ function PopoverArrowTrigger({
223223
>
224224
{children}
225225
</Paragraph>
226-
<DropdownIcon className={cn("size-4 min-w-4 transition", variantStyles.icon)} />
226+
{/* Wrapper only: `data-agent-no-drag` is an opt-out marker some draggable-window hosts
227+
check via `closest()`, so the icon (not the title text beside it) can decline a drag
228+
without changing this primitive's layout (`contents` keeps the icon as the flex item). */}
229+
<span data-agent-no-drag className="contents">
230+
<DropdownIcon className={cn("size-4 min-w-4 transition", variantStyles.icon)} />
231+
</span>
227232
</PopoverTrigger>
228233
);
229234
}

apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ export default function Story() {
6363
)}
6464
{mounted && open && (
6565
<FloatingAgentWindow fullscreen={fullscreen}>
66-
{(dragHandleProps) => (
66+
{({ dragHandleProps, dragHandleClassName }) => (
6767
<div className="flex h-full flex-col bg-background-bright">
68-
<motion.div {...dragHandleProps}>
68+
<motion.div {...dragHandleProps} className={dragHandleClassName}>
6969
<DashboardAgentHeader
7070
title="New chat"
7171
chats={NO_CHATS}

0 commit comments

Comments
 (0)