FN-7152: close Quick Chat on outside clicks
Quick Chat now dismisses from page clicks while other floating windows stay persistent. - Add an opt-in outside-pointer dismissal path to FloatingWindow with safeguards for inside clicks, nested dialogs, touch compatibility events, and active drag/resize gestures. - Enable outside-click dismissal only for the Quick Chat floating window and document the behavior. - Cover the opt-in behavior, default persistence, nested surfaces, gesture guards, and listener cleanup with FloatingWindow tests. - Add a release changeset for the Quick Chat dismissal feature. Files changed: .changeset/fn-7152-quick-chat-outside-click-close.md | 7 ++ docs/dashboard-guide.md | 1 + packages/dashboard/app/App.tsx | 4 + packages/dashboard/app/components/FloatingWindow.css | 5 +- packages/dashboard/app/components/FloatingWindow.tsx | 45 +++++++++ packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx | 109 +++++++++++++++++++++ 6 files changed, 170 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7152 Fusion-Task-Lineage: 5bd6c10a-3e21-4eb9-bd1e-99f3aaf9c7b6 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7152-quick-chat-outside-click-close.md
Normal file
7
.changeset/fn-7152-quick-chat-outside-click-close.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Close the Quick Chat window by clicking outside it.
|
||||
category: feature
|
||||
dev: New opt-in `closeOnOutsidePointerDown` prop on FloatingWindow; enabled only for the Quick Chat (windowKey="chat-modal"). Uses a capture-phase document pointerdown listener that excludes in-flight drag/resize and nested dialog/floating surfaces. Task pop-outs are unaffected.
|
||||
@@ -411,6 +411,7 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio
|
||||
- Submitting the inline chooser uses explicit fresh-session creation and immediately persists/selects the new thread, then refreshes the session dropdown list
|
||||
- On first open for a project, Quick Chat restores the last opened non-archived session from per-project local storage; if that saved session is missing, it falls back to the most recently touched non-archived session by latest activity (`max(lastMessageAt, updatedAt)`), and only falls back to the first agent / configured default model when no prior session exists.
|
||||
- Closing and reopening Quick Chat keeps the active conversation warm in memory, so messages stay visible without a conversation reload or "Loading conversation…" flash.
|
||||
- Clicking outside the desktop Quick Chat floating window closes it; task pop-out floating windows remain persistent on page clicks.
|
||||
- Queued follow-up messages entered while a Quick Chat response is still streaming now persist per session, so closing/reopening the panel restores the queued stack and flushes the messages one at a time in FIFO order as active responses complete.
|
||||
- Resume lookups still use targeted session queries instead of loading the full active-session list first
|
||||
- Tool-call summaries in the floating quick-chat panel are intentionally condensed into a single-line header row (especially on small screens) so tool name + status stay scannable without multi-line wrapping
|
||||
|
||||
@@ -1501,6 +1501,9 @@ function AppInner() {
|
||||
|
||||
FNXC:ChatModal 2026-06-22-14:57:
|
||||
Reopening Quick Chat from the FAB restores the last floating Chat window geometry through FloatingWindow's persisted/clamped geometry key. The modal's maximize button routes to the full Chat view and closes the floating modal without clearing ChatView's shared session selection state.
|
||||
|
||||
FNXC:ChatModal 2026-06-27-00:00:
|
||||
Quick Chat is a transient utility window, so it opts into FloatingWindow's outside-click dismissal in addition to minimize, close, and maximize controls. Task pop-outs intentionally do not opt in because they are persistent workspace windows that should survive page clicks.
|
||||
*/}
|
||||
{viewMode === "project" && currentProject && (
|
||||
<QuickChatFAB
|
||||
@@ -1514,6 +1517,7 @@ function AppInner() {
|
||||
windowKey="chat-modal"
|
||||
title="Chat"
|
||||
onClose={() => setQuickChatOpen(false)}
|
||||
closeOnOutsidePointerDown
|
||||
hideHeader
|
||||
dragHandleSelector=".chat-view--floating .view-header"
|
||||
className="floating-window--chat"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/*
|
||||
FNXC:FloatingWindow 2026-06-22-20:45:
|
||||
FloatingWindow is a non-blocking floating window (generalized from RightDockExpandModal). The overlay is a full-viewport, transparent, NON-dimming, NON-blurring, click-through layer: `pointer-events: none` lets every click pass through to the app and to other windows behind it. Only the panel re-enables `pointer-events: auto`. Because the overlay never intercepts clicks there is no overlay click-to-dismiss; the header close button is the only dismissal. Multiple overlays/panels coexist with no mutual blocking — z-stacking is driven by inline `z-index` from the component's per-window counter.
|
||||
FloatingWindow is a non-blocking floating window (generalized from RightDockExpandModal). The overlay is a full-viewport, transparent, NON-dimming, NON-blurring, click-through layer: `pointer-events: none` lets every click pass through to the app and to other windows behind it. Only the panel re-enables `pointer-events: auto`. Multiple overlays/panels coexist with no mutual blocking — z-stacking is driven by inline `z-index` from the component's per-window counter.
|
||||
|
||||
FNXC:FloatingWindow 2026-06-27-00:00:
|
||||
Click-through overlays cannot implement backdrop clicks in CSS/DOM structure. Outside-click dismissal is therefore a component-level opt-in document listener for transient windows such as Quick Chat; persistent task and terminal pop-outs keep the default non-dismissable page-click behavior.
|
||||
*/
|
||||
.floating-window-overlay {
|
||||
position: fixed;
|
||||
|
||||
@@ -47,6 +47,11 @@ export interface FloatingWindowProps {
|
||||
className?: string;
|
||||
/** Optional localStorage key used to restore the last clamped position and size. */
|
||||
persistGeometryKey?: string;
|
||||
/**
|
||||
* Opt-in outside-pointer dismissal for transient windows like Quick Chat.
|
||||
* Persistent task/terminal pop-outs must omit this so page clicks do not close them.
|
||||
*/
|
||||
closeOnOutsidePointerDown?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = 720;
|
||||
@@ -147,6 +152,7 @@ export function FloatingWindow({
|
||||
dragHandleSelector,
|
||||
className,
|
||||
persistGeometryKey,
|
||||
closeOnOutsidePointerDown = false,
|
||||
}: FloatingWindowProps) {
|
||||
const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT };
|
||||
const initialGeometry = useRef<{ size: FloatingWindowSize; position: FloatingWindowPosition } | null>(null);
|
||||
@@ -162,6 +168,7 @@ export function FloatingWindow({
|
||||
const [position, setPosition] = useState<FloatingWindowPosition>(() => initialGeometry.current!.position);
|
||||
// FNXC:FloatingWindow 2026-06-22-21:30: Each window owns its z-index; mounting claims the front of the SHARED cross-type stack.
|
||||
const [zIndex, setZIndex] = useState<number>(() => nextFloatingZ());
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
/*
|
||||
FNXC:FloatingWindow 2026-06-22-20:45:
|
||||
@@ -318,6 +325,43 @@ export function FloatingWindow({
|
||||
// FNXC:FloatingWindow 2026-06-22-20:45: Run any active drag/resize teardown on unmount so captured-element listeners + a pending rAF never outlive the window.
|
||||
useEffect(() => () => dragTeardownRef.current?.(), []);
|
||||
|
||||
/*
|
||||
FNXC:FloatingWindow 2026-06-27-00:00:
|
||||
Outside-click dismissal is opt-in because the overlay is intentionally click-through for coexisting floating windows. A capture-phase document pointerdown listener is the only reliable outside signal, and it must ignore in-flight drag/resize gestures plus nested modal/floating surfaces so Quick Chat can dismiss from bare-page clicks without making persistent task pop-outs fragile.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!closeOnOutsidePointerDown || typeof document === "undefined") return;
|
||||
|
||||
let lastTouchAt = 0;
|
||||
const markTouch = () => {
|
||||
lastTouchAt = Date.now();
|
||||
};
|
||||
const handleDocumentPointerDown = (event: PointerEvent) => {
|
||||
if (Date.now() - lastTouchAt < 500) return;
|
||||
if (dragTeardownRef.current) return;
|
||||
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
const panel = panelRef.current;
|
||||
if (panel?.contains(target)) return;
|
||||
|
||||
const targetElement = target instanceof Element ? target : target.parentNode instanceof Element ? target.parentNode : null;
|
||||
if (targetElement?.closest(".floating-window, .modal-overlay, [role=\"dialog\"]")) return;
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener("touchstart", markTouch, { passive: true });
|
||||
document.addEventListener("touchend", markTouch, { passive: true });
|
||||
document.addEventListener("pointerdown", handleDocumentPointerDown, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("touchstart", markTouch);
|
||||
document.removeEventListener("touchend", markTouch);
|
||||
document.removeEventListener("pointerdown", handleDocumentPointerDown, true);
|
||||
};
|
||||
}, [closeOnOutsidePointerDown, onClose]);
|
||||
|
||||
/*
|
||||
FNXC:ChatModal 2026-06-22-14:57:
|
||||
Quick Chat reopens should restore the last desktop floating-window size and position while still clamping onto the current viewport. Keep persistence generic for other FloatingWindow callers, but opt in with persistGeometryKey so existing task pop-outs remain ephemeral.
|
||||
@@ -353,6 +397,7 @@ export function FloatingWindow({
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`floating-window${hideHeader ? " floating-window--headerless" : ""}${className ? ` ${className}` : ""}`}
|
||||
style={panelStyle}
|
||||
data-testid={`floating-window-${windowKey}`}
|
||||
|
||||
@@ -108,6 +108,115 @@ describe("FloatingWindow", () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes on outside pointerdown only when the opt-in prop is enabled", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<FloatingWindow windowKey="outside-close" title="Outside close" onClose={onClose} closeOnOutsidePointerDown>
|
||||
<div>inside body</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(document.body);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not close for inside pointerdown when outside dismissal is enabled", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<FloatingWindow windowKey="inside-safe" title="Inside safe" onClose={onClose} closeOnOutsidePointerDown>
|
||||
<button type="button">Inside action</button>
|
||||
</FloatingWindow>
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByText("Inside action"));
|
||||
fireEvent.pointerDown(screen.getByTestId("floating-window-body-inside-safe"));
|
||||
fireEvent.pointerDown(screen.getByTestId("floating-window-inside-safe"));
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps page clicks non-dismissive by default for persistent floating windows", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<FloatingWindow windowKey="persistent" title="Persistent" onClose={onClose}>
|
||||
<div>persistent body</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(document.body);
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not close when the outside target is another floating or dialog surface", () => {
|
||||
for (const surfaceClassOrRole of ["modal-overlay", "floating-window", "dialog-role"] as const) {
|
||||
const onClose = vi.fn();
|
||||
const { unmount } = render(
|
||||
<FloatingWindow windowKey={`nested-${surfaceClassOrRole}`} title="Nested safe" onClose={onClose} closeOnOutsidePointerDown>
|
||||
<div>chat body</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
const surface = document.createElement("div");
|
||||
if (surfaceClassOrRole === "dialog-role") {
|
||||
surface.setAttribute("role", "dialog");
|
||||
} else {
|
||||
surface.className = surfaceClassOrRole;
|
||||
}
|
||||
document.body.appendChild(surface);
|
||||
|
||||
fireEvent.pointerDown(surface);
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
surface.remove();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not close from outside pointerdown while a resize gesture is active", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<FloatingWindow windowKey="resize-safe" title="Resize safe" onClose={onClose} closeOnOutsidePointerDown>
|
||||
<div>resize body</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByTestId("floating-window-resize-se"), { pointerId: 1 });
|
||||
fireEvent.pointerDown(document.body);
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores compatibility pointer events immediately after touch gestures", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<FloatingWindow windowKey="touch-safe" title="Touch safe" onClose={onClose} closeOnOutsidePointerDown>
|
||||
<div>touch body</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
fireEvent.touchStart(document);
|
||||
fireEvent.touchEnd(document);
|
||||
fireEvent.pointerDown(document.body);
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the outside pointerdown listener on unmount", () => {
|
||||
const onClose = vi.fn();
|
||||
const { unmount } = render(
|
||||
<FloatingWindow windowKey="cleanup" title="Cleanup" onClose={onClose} closeOnOutsidePointerDown>
|
||||
<div>cleanup body</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
|
||||
unmount();
|
||||
fireEvent.pointerDown(document.body);
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("multiple windows coexist independently (each renders its own panel)", () => {
|
||||
render(
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user