diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 08fae9a7..8f54c6fd 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -257,6 +257,8 @@
"Copy": "Copy",
"Copy to space": "Copy to space",
"Copy chat": "Copy chat",
+ "Dock to sidebar": "Dock to sidebar",
+ "Undock": "Undock",
"Copied": "Copied",
"Failed to export chat": "Failed to export chat",
"Duplicate": "Duplicate",
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index 88629662..92a47b45 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -715,6 +715,8 @@
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
"Ask the AI agent…": "Спросите AI-агента…",
"Copy chat": "Копировать чат",
+ "Dock to sidebar": "Закрепить в боковой панели",
+ "Undock": "Открепить",
"Created successfully": "Успешно создано",
"Context size / model limit": "Размер контекста / лимит модели",
"Context window (tokens)": "Окно контекста (токены)",
diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx
index b756bdde..41d3886f 100644
--- a/apps/client/src/components/layouts/global/global-app-shell.tsx
+++ b/apps/client/src/components/layouts/global/global-app-shell.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai";
import {
+ APP_NAVBAR_ID,
asideStateAtom,
desktopSidebarAtom,
mobileSidebarAtom,
@@ -106,6 +107,7 @@ export default function GlobalAppShell({
). Declared here —
+// alongside the sidebar atoms — rather than in the chat window so the AI chat
+// window can reference the navbar by id without importing the app shell (which
+// would create a shell -> chat-window -> shell import cycle).
+export const APP_NAVBAR_ID = "app-shell-navbar";
+
export const mobileSidebarAtom = atom(false);
export const desktopSidebarAtom = atomWithWebStorage(
diff --git a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts
index abf63729..3b56a30a 100644
--- a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts
+++ b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts
@@ -18,6 +18,18 @@ export const aiChatWindowGeomAtom = atomWithStorage(
null,
);
+/**
+ * Whether the AI chat window is docked into the sidebar (page-tree navbar).
+ * Persisted to localStorage so the docked/floating mode survives a full page
+ * reload and close/reopen. `false` = the default floating window. When docked,
+ * the SAME window instance pins itself to the live bounding rect of the app
+ * navbar (see AiChatWindow), overlaying the page tree.
+ */
+export const aiChatWindowDockedAtom = atomWithStorage(
+ "ai-chat-window-docked",
+ false,
+);
+
/**
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:
* the server creates the chat row on the first streamed message and echoes its
diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css
index 5758a018..659ca8cb 100644
--- a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css
+++ b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css
@@ -35,6 +35,35 @@
background: transparent;
}
+/* Docked into the sidebar: the window pins itself to the live navbar rect
+ (position/size supplied inline). It sits flush inside the navbar area, so we
+ drop the floating chrome — no border-radius, drop shadow or user resize — and
+ remove the floating min/max clamps so the size is driven ENTIRELY by the
+ inline navbar rect (which may be narrower than the floating min-width of
+ 300px, e.g. the 220px navbar minimum). z-index 105 keeps it above the page
+ tree (navbar 101) but below the header and Mantine overlays. */
+.docked {
+ border-radius: 0;
+ box-shadow: none;
+ resize: none;
+ min-width: 0;
+ min-height: 0;
+ max-width: none;
+ max-height: none;
+}
+
+/* Drop-zone highlight shown over the navbar bounds while a floating window is
+ dragged onto the sidebar. Sits just above the docked window (106) so the cue
+ is visible; purely decorative, so it never intercepts pointer events. */
+.dockHighlight {
+ position: fixed;
+ z-index: 106;
+ border: 2px dashed light-dark(var(--mantine-color-blue-5), var(--mantine-color-blue-4));
+ background: light-dark(rgba(34, 139, 230, 0.08), rgba(34, 139, 230, 0.14));
+ border-radius: var(--mantine-radius-sm);
+ pointer-events: none;
+}
+
/* When minimized the window collapses to the header only: auto height, no
resize. Width/height inline values are overridden. */
.minimized {
diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx
index 3df60ddb..94923771 100644
--- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx
+++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx
@@ -13,21 +13,29 @@ import {
IconChevronDown,
IconCopy,
IconGripVertical,
+ IconLayoutSidebarLeftCollapse,
+ IconLayoutSidebarLeftExpand,
IconMinus,
IconPlus,
IconX,
} from "@tabler/icons-react";
import { useAtom, useSetAtom } from "jotai";
-import { useMatch } from "react-router-dom";
+import { useLocation, useMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatWindowGeomAtom,
+ aiChatWindowDockedAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
+import {
+ APP_NAVBAR_ID,
+ desktopSidebarAtom,
+ mobileSidebarAtom,
+} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import {
@@ -46,6 +54,10 @@ import {
isHeaderClick,
} from "@/features/ai-chat/utils/collapse-helpers.ts";
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
+import {
+ isPointWithinRect,
+ type NavbarRect,
+} from "@/features/ai-chat/utils/dock-helpers.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { notifications } from "@mantine/notifications";
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
@@ -112,6 +124,27 @@ function clampGeom(g: {
};
}
+// Live bounding rect of the app-shell navbar (the page-tree sidebar), by its
+// stable id. Returns null when the navbar is absent OR collapsed: Mantine
+// collapses the navbar by translating it off-screen (its right edge lands at or
+// left of the viewport), so a zero-size or off-screen rect is treated as "no
+// navbar" — the docked window then falls back to floating instead of pinning to
+// an off-screen box. Reads the DOM, so call it inside effects / handlers only.
+function getNavbarRect(): NavbarRect | null {
+ const el = document.getElementById(APP_NAVBAR_ID);
+ if (!el) return null;
+ const r = el.getBoundingClientRect();
+ if (r.width === 0 || r.height === 0 || r.right <= 0) return null;
+ return { left: r.left, top: r.top, width: r.width, height: r.height };
+}
+
+// Whether a viewport point falls within the (visible) navbar bounds. Used to
+// decide dock-on-drop and undock-on-drag-out. The point-in-rect math is the pure
+// isPointWithinRect helper (unit-tested); this only supplies the live rect.
+function isPointerOverNavbar(x: number, y: number): boolean {
+ return isPointWithinRect(x, y, getNavbarRect());
+}
+
/**
* Floating, draggable, resizable, minimizable AI chat window. Replaces the
* former right-aside `AiChatPanel`: it owns ALL chat orchestration (active
@@ -138,6 +171,43 @@ export default function AiChatWindow() {
const minimizedRef = useRef(minimized);
minimizedRef.current = minimized;
+ // Docked-into-sidebar mode (#276). Persisted so it survives reload + reopen.
+ // When docked the SAME window instance pins itself to the navbar rect below.
+ const [docked, setDocked] = useAtom(aiChatWindowDockedAtom);
+ // Mirror for the useCallback([]) drag handlers (same reason as minimizedRef).
+ const dockedRef = useRef(docked);
+ dockedRef.current = docked;
+ // Live navbar rect the docked window is pinned to; synced before paint by the
+ // layout effect below. null = navbar absent/collapsed -> floating fallback.
+ const [dockRect, setDockRect] = useState(null);
+ // While dragging a FLOATING window over the navbar: show the drop-zone hint.
+ const [dockHint, setDockHint] = useState(false);
+ // Live window position during a drag. Normally the drag is fully imperative
+ // (el.style updated per mousemove, no re-render — matching the pre-#276
+ // behavior), so this stays null. It is set ONLY at a navbar-boundary crossing:
+ // that crossing already forces a re-render (dockHint flips), which would
+ // otherwise re-apply the committed geom and snap the box back for a frame — so
+ // we hand the render the live position at that instant instead. Cleared on drop.
+ const [dragPos, setDragPos] = useState<{ left: number; top: number } | null>(
+ null,
+ );
+
+ // Subscribed (read-only) so this component re-renders — and the dockRect-sync
+ // effect below re-runs — when the sidebar is collapsed/expanded via the header
+ // toggle. Mantine collapses the navbar with a transform (width/border-box
+ // unchanged), so the navbar's ResizeObserver never fires; these deps + the
+ // navbar `transitionend` listener are what re-measure the rect on toggle.
+ const [desktopSidebarOpen] = useAtom(desktopSidebarAtom);
+ const [mobileSidebarOpen] = useAtom(mobileSidebarAtom);
+
+ // Dock mode is only EFFECTIVE when a navbar rect is available. When docked but
+ // the navbar is absent/collapsed (dockRect === null) the window falls back to
+ // the floating look, so effects gated on "is docked" must use this — not the
+ // raw `docked` flag — or a fallback-floating window would behave half-docked.
+ const useDock = docked && dockRect !== null;
+
+ const location = useLocation();
+
const winRef = useRef(null);
// Live window geometry (position + size); persisted to localStorage so a
// drag/resize survives a full page reload (and close/reopen). `null` means
@@ -325,6 +395,47 @@ export default function AiChatWindow() {
setMinimized(false);
}, [windowOpen]);
+ // While docked, keep the window pinned to the navbar's LIVE rect. useLayoutEffect
+ // (not useEffect) so dockRect is measured/committed before the browser paints,
+ // avoiding a first-frame jump. Re-measures on: navbar size changes (manual
+ // sidebar resize -> ResizeObserver), viewport resize (window `resize`), and
+ // route changes that swap the navbar width (space <-> shared/global sidebar are
+ // 300px vs sidebarWidth -> re-run on location.pathname). If the navbar is
+ // absent/collapsed, getNavbarRect() returns null and the render falls back to
+ // the floating look (the window does NOT vanish).
+ useLayoutEffect(() => {
+ if (!windowOpen || !docked) return;
+ const sync = () => setDockRect(getNavbarRect());
+ sync();
+ const navbar = document.getElementById(APP_NAVBAR_ID);
+ let ro: ResizeObserver | null = null;
+ if (navbar) {
+ ro = new ResizeObserver(sync);
+ ro.observe(navbar);
+ // Collapsing/expanding the sidebar translates the navbar off-screen WITHOUT
+ // changing its width/border-box, so the ResizeObserver never fires and the
+ // effect's initial sync() may measure mid-transition (stale). Re-measure at
+ // transitionend so getNavbarRect() sees the final position: null once the
+ // navbar is translated off (right <= 0) -> fall back to floating; the real
+ // rect once it slides back -> re-dock. The sidebar-state deps below force
+ // this effect (and the immediate sync) to re-run on each toggle, covering
+ // the reduced-motion case where no transition -> no transitionend.
+ navbar.addEventListener("transitionend", sync);
+ }
+ window.addEventListener("resize", sync);
+ return () => {
+ ro?.disconnect();
+ navbar?.removeEventListener("transitionend", sync);
+ window.removeEventListener("resize", sync);
+ };
+ }, [
+ windowOpen,
+ docked,
+ location.pathname,
+ desktopSidebarOpen,
+ mobileSidebarOpen,
+ ]);
+
// Auto-collapse the window into its header as soon as the user interacts with
// anything outside it (clicks the page/editor). Armed ONLY while the window is
// open and expanded, so it never fires repeatedly and never collapses on the
@@ -333,7 +444,12 @@ export default function AiChatWindow() {
// (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside
// the window or inside Mantine portals (kebab menu, delete-confirm modal).
useEffect(() => {
- if (!windowOpen || minimized) return;
+ // Disabled while EFFECTIVELY docked: a docked window intentionally overlays
+ // the page tree, so a click on the surrounding page must NOT auto-collapse
+ // it. Gated on useDock (not raw `docked`) so a fallback-floating window
+ // (docked but navbar absent/collapsed) still auto-collapses like a normal
+ // floating window.
+ if (!windowOpen || minimized || useDock) return;
const onPointerDown = (e: MouseEvent): void => {
if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) {
setMinimized(true);
@@ -341,13 +457,18 @@ export default function AiChatWindow() {
};
document.addEventListener("mousedown", onPointerDown, true);
return () => document.removeEventListener("mousedown", onPointerDown, true);
- }, [windowOpen, minimized]);
+ }, [windowOpen, minimized, useDock]);
// Persist the user's resize into state so it survives close/reopen. Skipped
// while minimized so the collapsed (auto) height is never captured. The
// equality guard avoids an update loop.
useEffect(() => {
- if (!windowOpen || minimized) return;
+ // Disabled while EFFECTIVELY docked: in dock mode the size is driven by the
+ // navbar rect, not a user resize, so we must not capture the navbar-sized box
+ // into the persisted floating geom (it would clobber the remembered floating
+ // size). Gated on useDock so a fallback-floating window (docked but navbar
+ // absent) still persists user resizes like a normal floating window.
+ if (!windowOpen || minimized || useDock) return;
const el = winRef.current;
// `geom` is in the deps so this re-runs once geometry is settled and the
// window is actually rendered (on the first open `geom` is still null on the
@@ -365,18 +486,30 @@ export default function AiChatWindow() {
});
ro.observe(el);
return () => ro.disconnect();
- }, [windowOpen, minimized, geom !== null]);
+ }, [windowOpen, minimized, useDock, geom !== null]);
const startDrag = useCallback((e: React.MouseEvent): void => {
- // Ignore drags that originate on a button (minimize/close/new chat).
+ // Ignore drags that originate on a button (dock/minimize/close/new chat).
if ((e.target as HTMLElement).closest("button")) return;
const el = winRef.current;
if (!el) return;
const sx = e.clientX;
const sy = e.clientY;
+ // Starting position: the element's current inline left/top, whether it was
+ // placed by the floating geom or pinned to the navbar rect (both render as
+ // "px"). getBoundingClientRect would work too, but the inline values keep
+ // the drag math identical to the pre-#276 floating behavior.
const ol = parseFloat(el.style.left) || 0;
const ot = parseFloat(el.style.top) || 0;
+ // Freeze the box size for the drag: a docked window keeps its navbar size
+ // while being pulled out, a floating window keeps its own size.
+ const dragW = el.offsetWidth;
+ const dragH = el.offsetHeight;
+
+ // Latch for the drop-zone hint so setState fires only when the pointer
+ // actually crosses the navbar boundary, not on every mousemove.
+ let overNavbar = false;
const move = (ev: MouseEvent): void => {
let nl = ol + (ev.clientX - sx);
@@ -385,20 +518,58 @@ export default function AiChatWindow() {
// with position: fixed) with an 8px margin.
nl = Math.max(
EDGE_MARGIN,
- Math.min(nl, window.innerWidth - el.offsetWidth - EDGE_MARGIN),
+ Math.min(nl, window.innerWidth - dragW - EDGE_MARGIN),
);
nt = Math.max(
EDGE_MARGIN,
- Math.min(nt, window.innerHeight - el.offsetHeight - EDGE_MARGIN),
+ Math.min(nt, window.innerHeight - dragH - EDGE_MARGIN),
);
el.style.left = `${nl}px`;
el.style.top = `${nt}px`;
+ // Drop-zone highlight: only meaningful when dragging a FLOATING window in
+ // to dock it (a docked window is already over the navbar).
+ if (!dockedRef.current) {
+ const nowOver = isPointerOverNavbar(ev.clientX, ev.clientY);
+ if (nowOver !== overNavbar) {
+ overNavbar = nowOver;
+ // This re-render would re-apply the committed geom; hand it the live
+ // position so the box does not snap back for a frame.
+ setDragPos({ left: nl, top: nt });
+ setDockHint(nowOver);
+ }
+ }
};
const up = (ev: MouseEvent): void => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
document.body.style.userSelect = "";
+ setDragPos(null);
+ setDockHint(false);
+ const overNavbarNow = isPointerOverNavbar(ev.clientX, ev.clientY);
+
+ if (dockedRef.current) {
+ // Docked window: releasing OUTSIDE the navbar pops it out as a floating
+ // window at the drop point (clamped to the viewport). Released over the
+ // navbar -> stays docked (a header click is a no-op here). The response
+ // stream is untouched — only the mode flag / geom change.
+ if (!overNavbarNow) {
+ const el2 = winRef.current;
+ const dropLeft = el2 ? parseFloat(el2.style.left) || 0 : 0;
+ const dropTop = el2 ? parseFloat(el2.style.top) || 0 : 0;
+ setGeom((prev) =>
+ clampGeom({
+ ...(prev ?? computeInitialGeom()),
+ left: dropLeft,
+ top: dropTop,
+ }),
+ );
+ setDocked(false);
+ }
+ return;
+ }
+
+ // Floating window.
// Treat a near-zero-movement press as a click (not a drag). When the
// window is minimized, a header click expands it; nothing to persist
// because the position did not change. minimizedRef avoids the stale
@@ -410,6 +581,13 @@ export default function AiChatWindow() {
setMinimized(false);
return;
}
+ // Released over the navbar -> dock. The layout effect then pins the window
+ // to the navbar rect; the last floating geom is left untouched so a later
+ // undock/close restores the remembered floating placement.
+ if (overNavbarNow) {
+ setDocked(true);
+ return;
+ }
const el2 = winRef.current;
// Persist the final position back into state (preserving the size) so
// re-renders keep it.
@@ -432,6 +610,20 @@ export default function AiChatWindow() {
e.preventDefault();
}, []);
+ // Dock/undock via the header button. Docking pins the window to the navbar;
+ // undocking restores the floating window at its last remembered geom. On
+ // undock we re-clamp that geom to the current viewport (matching drag-undock's
+ // clampGeom) so a viewport shrink while docked can't leave the popped-out
+ // window partly off-screen. The chat thread stays mounted across the toggle,
+ // so a live stream is intact. dockedRef gives the live value inside this
+ // useCallback([]) handler.
+ const toggleDock = useCallback((): void => {
+ if (dockedRef.current) {
+ setGeom((prev) => (prev ? clampGeom(prev) : prev));
+ }
+ setDocked((d) => !d);
+ }, [setDocked, setGeom]);
+
// Just toggle the flag. The `.minimized` CSS handles the collapsed height and
// disables resize, and `.minimized .content` hides the body while keeping
// ChatThread mounted (so an in-flight stream is not aborted).
@@ -441,17 +633,45 @@ export default function AiChatWindow() {
if (!windowOpen || !geom) return null;
- return (
-
+
{/* drag bar / header. Mouse users expand a minimized window by clicking
anywhere on the bar (the click-vs-drag logic in startDrag, which
@@ -471,11 +691,11 @@ export default function AiChatWindow() {
is a plain, non-focusable label. */}
{
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
@@ -531,15 +751,35 @@ export default function AiChatWindow() {
)}
)}
+ {/* Dock/undock toggle. Docked -> "Undock" (expand icon) pops the window
+ back out to floating; floating -> "Dock to sidebar" (collapse icon)
+ pins it into the navbar. */}
+ {/* Minimize (collapse to header) makes no sense while docked — the
+ window fills the navbar — so it is hidden in dock mode. */}
+ {!useDock && (
+
+ )}
- {/* resize affordance icon (drawn manually; native resizer is hidden) */}
- {!minimized && (
+ {/* resize affordance icon (drawn manually; native resizer is hidden).
+ Hidden while docked — the docked size follows the navbar, not a manual
+ resize. */}
+ {!showMinimized && !useDock && (
)}
+ {/* Drop-zone highlight over the navbar while dragging a floating window in
+ to dock it. Sibling of the window (position: fixed) so it tracks the
+ navbar bounds, not the moving window. */}
+ {hintRect && (
+
+ )}
+ >
);
}
diff --git a/apps/client/src/features/ai-chat/utils/dock-helpers.test.ts b/apps/client/src/features/ai-chat/utils/dock-helpers.test.ts
new file mode 100644
index 00000000..7d288ef1
--- /dev/null
+++ b/apps/client/src/features/ai-chat/utils/dock-helpers.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from "vitest";
+import { isPointWithinRect, type NavbarRect } from "./dock-helpers.ts";
+
+const NAVBAR: NavbarRect = { left: 0, top: 45, width: 300, height: 800 };
+
+describe("isPointWithinRect", () => {
+ it("returns true for a point inside the navbar", () => {
+ expect(isPointWithinRect(150, 400, NAVBAR)).toBe(true);
+ });
+
+ it("treats the boundary edges as inside (drop exactly on the edge docks)", () => {
+ // Top-left corner and bottom-right corner are both inclusive.
+ expect(isPointWithinRect(0, 45, NAVBAR)).toBe(true);
+ expect(isPointWithinRect(300, 845, NAVBAR)).toBe(true);
+ });
+
+ it("returns false for a point in the content area (to the right)", () => {
+ expect(isPointWithinRect(500, 400, NAVBAR)).toBe(false);
+ });
+
+ it("returns false above the navbar (in the header band)", () => {
+ expect(isPointWithinRect(150, 10, NAVBAR)).toBe(false);
+ });
+
+ it("returns false when the navbar rect is null (absent/collapsed)", () => {
+ expect(isPointWithinRect(150, 400, null)).toBe(false);
+ });
+});
diff --git a/apps/client/src/features/ai-chat/utils/dock-helpers.ts b/apps/client/src/features/ai-chat/utils/dock-helpers.ts
new file mode 100644
index 00000000..b2e7f2f1
--- /dev/null
+++ b/apps/client/src/features/ai-chat/utils/dock-helpers.ts
@@ -0,0 +1,32 @@
+// Pure geometry helper for the AI chat window dock/undock decision (#276). Kept
+// free of React and the DOM so it can be unit-tested in isolation (see
+// dock-helpers.test.ts). The DOM-reading getNavbarRect() lives in the window
+// component; this is only the point-in-rect math that decides dock-on-drop and
+// undock-on-drag-out from the measured navbar rect.
+
+export type NavbarRect = {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+};
+
+/**
+ * Whether a viewport point (x, y) falls within `rect`. Edges are inclusive so a
+ * drop exactly on the navbar boundary counts as "over the navbar". Returns false
+ * when the rect is null (navbar absent/collapsed) so the caller falls back to the
+ * floating behavior.
+ */
+export function isPointWithinRect(
+ x: number,
+ y: number,
+ rect: NavbarRect | null,
+): boolean {
+ if (!rect) return false;
+ return (
+ x >= rect.left &&
+ x <= rect.left + rect.width &&
+ y >= rect.top &&
+ y <= rect.top + rect.height
+ );
+}