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 - 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. - 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. - 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. - 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 - 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 - 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: 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. 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 && ( {viewMode === "project" && currentProject && (
<QuickChatFAB <QuickChatFAB
@@ -1514,6 +1517,7 @@ function AppInner() {
windowKey="chat-modal" windowKey="chat-modal"
title="Chat" title="Chat"
onClose={() => setQuickChatOpen(false)} onClose={() => setQuickChatOpen(false)}
closeOnOutsidePointerDown
hideHeader hideHeader
dragHandleSelector=".chat-view--floating .view-header" dragHandleSelector=".chat-view--floating .view-header"
className="floating-window--chat" className="floating-window--chat"

View File

@@ -1,6 +1,9 @@
/* /*
FNXC:FloatingWindow 2026-06-22-20:45: 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 { .floating-window-overlay {
position: fixed; position: fixed;

View File

@@ -47,6 +47,11 @@ export interface FloatingWindowProps {
className?: string; className?: string;
/** Optional localStorage key used to restore the last clamped position and size. */ /** Optional localStorage key used to restore the last clamped position and size. */
persistGeometryKey?: string; 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; const DEFAULT_WIDTH = 720;
@@ -147,6 +152,7 @@ export function FloatingWindow({
dragHandleSelector, dragHandleSelector,
className, className,
persistGeometryKey, persistGeometryKey,
closeOnOutsidePointerDown = false,
}: FloatingWindowProps) { }: FloatingWindowProps) {
const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT }; const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT };
const initialGeometry = useRef<{ size: FloatingWindowSize; position: FloatingWindowPosition } | null>(null); const initialGeometry = useRef<{ size: FloatingWindowSize; position: FloatingWindowPosition } | null>(null);
@@ -162,6 +168,7 @@ export function FloatingWindow({
const [position, setPosition] = useState<FloatingWindowPosition>(() => initialGeometry.current!.position); 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. // 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 [zIndex, setZIndex] = useState<number>(() => nextFloatingZ());
const panelRef = useRef<HTMLDivElement | null>(null);
/* /*
FNXC:FloatingWindow 2026-06-22-20:45: 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. // 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?.(), []); 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: 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. 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 }} style={{ zIndex }}
> >
<div <div
ref={panelRef}
className={`floating-window${hideHeader ? " floating-window--headerless" : ""}${className ? ` ${className}` : ""}`} className={`floating-window${hideHeader ? " floating-window--headerless" : ""}${className ? ` ${className}` : ""}`}
style={panelStyle} style={panelStyle}
data-testid={`floating-window-${windowKey}`} data-testid={`floating-window-${windowKey}`}

View File

@@ -108,6 +108,115 @@ describe("FloatingWindow", () => {
expect(onClose).toHaveBeenCalledTimes(1); 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)", () => { it("multiple windows coexist independently (each renders its own panel)", () => {
render( render(
<> <>