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:
gsxdsm
2026-06-27 19:48:50 -07:00
parent 100164dc50
commit 6ca51185f3
6 changed files with 170 additions and 1 deletions

View 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.

View File

@@ -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

View File

@@ -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"

View File

@@ -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;

View File

@@ -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}`}

View File

@@ -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(
<>