diff --git a/.changeset/fn-7152-quick-chat-outside-click-close.md b/.changeset/fn-7152-quick-chat-outside-click-close.md new file mode 100644 index 0000000000..d89d0a5cf8 --- /dev/null +++ b/.changeset/fn-7152-quick-chat-outside-click-close.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 2cc4cd9bdb..82b0f1fe8c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index efc906fe88..befe5099c0 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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 && ( setQuickChatOpen(false)} + closeOnOutsidePointerDown hideHeader dragHandleSelector=".chat-view--floating .view-header" className="floating-window--chat" diff --git a/packages/dashboard/app/components/FloatingWindow.css b/packages/dashboard/app/components/FloatingWindow.css index 3c8d2ec2c5..16b16a9c55 100644 --- a/packages/dashboard/app/components/FloatingWindow.css +++ b/packages/dashboard/app/components/FloatingWindow.css @@ -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; diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index b5c0c30086..b660158471 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -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(() => 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(() => nextFloatingZ()); + const panelRef = useRef(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 }} >
{ expect(onClose).toHaveBeenCalledTimes(1); }); + it("closes on outside pointerdown only when the opt-in prop is enabled", () => { + const onClose = vi.fn(); + render( + +
inside body
+
+ ); + + 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( + + + + ); + + 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( + +
persistent body
+
+ ); + + 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( + +
chat body
+
+ ); + 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( + +
resize body
+
+ ); + + 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( + +
touch body
+
+ ); + + 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( + +
cleanup body
+
+ ); + + unmount(); + fireEvent.pointerDown(document.body); + + expect(onClose).not.toHaveBeenCalled(); + }); + it("multiple windows coexist independently (each renders its own panel)", () => { render( <>