Merge pull request 'feat(ai-chat): auto-collapse chat window on page focus (#42)' (#50) from feat/ai-chat-collapse-on-focus into develop
Some checks failed
Develop / test (push) Has been cancelled
Develop / build (push) Has been cancelled
Some checks failed
Develop / test (push) Has been cancelled
Develop / build (push) Has been cancelled
This commit was merged in pull request #50.
This commit is contained in:
@@ -57,6 +57,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* In the collapsed state the header expands the window on click, so hint that
|
||||
it is clickable (override the drag `grab` cursor). */
|
||||
.minimized .dragBar {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dragBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -38,6 +38,10 @@ import {
|
||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
|
||||
import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
isHeaderClick,
|
||||
} from "@/features/ai-chat/utils/collapse-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";
|
||||
@@ -110,6 +114,10 @@ export default function AiChatWindow() {
|
||||
// History section starts collapsed (matches the former panel's behavior).
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
// Mirror of `minimized` for handlers wrapped in useCallback([]) (startDrag),
|
||||
// which would otherwise close over a stale value. Kept in sync below.
|
||||
const minimizedRef = useRef(minimized);
|
||||
minimizedRef.current = minimized;
|
||||
|
||||
const winRef = useRef<HTMLDivElement>(null);
|
||||
// Live window geometry (position + size); initialized lazily on first open so
|
||||
@@ -257,8 +265,31 @@ export default function AiChatWindow() {
|
||||
useLayoutEffect(() => {
|
||||
if (!windowOpen) return;
|
||||
setGeom((prev) => (prev ? clampGeom(prev) : computeInitialGeom()));
|
||||
// Always show the window expanded on (re)open: a collapsed state from a
|
||||
// previous open session must not stick. Runs before paint so the first
|
||||
// frame is already expanded. The composer's autofocus is a focus INSIDE the
|
||||
// window (not an outside mousedown), so it cannot self-collapse the window.
|
||||
setMinimized(false);
|
||||
}, [windowOpen]);
|
||||
|
||||
// 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
|
||||
// open→reset transition. Capture phase so a page handler's stopPropagation in
|
||||
// the bubble phase can't hide the event from us; the in-window/portal guards
|
||||
// (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside
|
||||
// the window or inside Mantine portals (kebab menu, delete-confirm modal).
|
||||
useEffect(() => {
|
||||
if (!windowOpen || minimized) return;
|
||||
const onPointerDown = (e: MouseEvent): void => {
|
||||
if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) {
|
||||
setMinimized(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown, true);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown, true);
|
||||
}, [windowOpen, minimized]);
|
||||
|
||||
// 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.
|
||||
@@ -306,10 +337,21 @@ export default function AiChatWindow() {
|
||||
el.style.top = `${nt}px`;
|
||||
};
|
||||
|
||||
const up = (): void => {
|
||||
const up = (ev: MouseEvent): void => {
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", up);
|
||||
document.body.style.userSelect = "";
|
||||
// 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
|
||||
// `minimized` captured by useCallback([]).
|
||||
if (
|
||||
minimizedRef.current &&
|
||||
isHeaderClick(sx, sy, ev.clientX, ev.clientY)
|
||||
) {
|
||||
setMinimized(false);
|
||||
return;
|
||||
}
|
||||
const el2 = winRef.current;
|
||||
// Persist the final position back into state (preserving the size) so
|
||||
// re-renders keep it.
|
||||
@@ -353,14 +395,40 @@ export default function AiChatWindow() {
|
||||
height: minimized ? undefined : geom.height,
|
||||
}}
|
||||
>
|
||||
{/* drag bar / header */}
|
||||
{/* drag bar / header. Mouse users expand a minimized window by clicking
|
||||
anywhere on the bar (the click-vs-drag logic in startDrag, which
|
||||
excludes the buttons). The keyboard/screen-reader Expand affordance
|
||||
lives on the title element below — NOT on this container — so we never
|
||||
nest the Minimize/Close <button>s inside an element with
|
||||
role="button" (invalid ARIA: nested interactive controls). */}
|
||||
<div className={classes.dragBar} onMouseDown={startDrag}>
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
color="var(--mantine-color-gray-4)"
|
||||
style={{ flex: "none" }}
|
||||
/>
|
||||
<span className={classes.title}>{t("AI chat")}</span>
|
||||
{/* When minimized, the title doubles as the keyboard Expand button:
|
||||
it carries role/tabIndex/aria-label and an Enter/Space handler, and
|
||||
unlike the dragBar it contains no nested <button>s. When expanded it
|
||||
is a plain, non-focusable label. */}
|
||||
<span
|
||||
className={classes.title}
|
||||
role={minimized ? "button" : undefined}
|
||||
tabIndex={minimized ? 0 : undefined}
|
||||
aria-label={minimized ? t("Expand") : undefined}
|
||||
onKeyDown={
|
||||
minimized
|
||||
? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setMinimized(false);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("AI chat")}
|
||||
</span>
|
||||
|
||||
{/* Role badge for the active chat (emoji + name). Shown only when the
|
||||
chat is bound to a role that still exists. */}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
isHeaderClick,
|
||||
} from "./collapse-helpers";
|
||||
|
||||
describe("shouldCollapseOnOutsidePointer", () => {
|
||||
let windowEl: HTMLDivElement;
|
||||
let inside: HTMLSpanElement;
|
||||
let portal: HTMLDivElement;
|
||||
let portalChild: HTMLButtonElement;
|
||||
let page: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// The floating window with a child node.
|
||||
windowEl = document.createElement("div");
|
||||
inside = document.createElement("span");
|
||||
windowEl.appendChild(inside);
|
||||
|
||||
// A Mantine-style portal (data-portal="true") with a child (e.g. a menu item).
|
||||
portal = document.createElement("div");
|
||||
portal.setAttribute("data-portal", "true");
|
||||
portalChild = document.createElement("button");
|
||||
portal.appendChild(portalChild);
|
||||
|
||||
// An unrelated page element.
|
||||
page = document.createElement("div");
|
||||
|
||||
document.body.append(windowEl, portal, page);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("returns false for a target inside the window", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(inside, windowEl)).toBe(false);
|
||||
expect(shouldCollapseOnOutsidePointer(windowEl, windowEl)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a target inside a Mantine portal", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(portal, windowEl)).toBe(false);
|
||||
expect(shouldCollapseOnOutsidePointer(portalChild, windowEl)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a target on the page (outside window and portals)", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(page, windowEl)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when there is no window element", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(page, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a non-Element target", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(null, windowEl)).toBe(false);
|
||||
expect(shouldCollapseOnOutsidePointer(document, windowEl)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHeaderClick", () => {
|
||||
it("treats a zero-movement press as a click", () => {
|
||||
expect(isHeaderClick(100, 100, 100, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats movement within the threshold as a click", () => {
|
||||
expect(isHeaderClick(100, 100, 103, 97)).toBe(true);
|
||||
expect(isHeaderClick(100, 100, 104, 104)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats movement beyond the threshold (either axis) as a drag", () => {
|
||||
expect(isHeaderClick(100, 100, 105, 100)).toBe(false);
|
||||
expect(isHeaderClick(100, 100, 100, 105)).toBe(false);
|
||||
});
|
||||
|
||||
it("honors a custom threshold", () => {
|
||||
expect(isHeaderClick(0, 0, 8, 0, 10)).toBe(true);
|
||||
expect(isHeaderClick(0, 0, 11, 0, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
41
apps/client/src/features/ai-chat/utils/collapse-helpers.ts
Normal file
41
apps/client/src/features/ai-chat/utils/collapse-helpers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Pure helpers for the AI chat window auto-collapse behavior. Kept free of React
|
||||
// so they can be unit-tested in isolation (see collapse-helpers.test.ts).
|
||||
|
||||
/**
|
||||
* Decide whether an outside pointer (mousedown) should collapse the chat window.
|
||||
*
|
||||
* Returns true only when the pointer target is genuinely "on the page": NOT
|
||||
* inside the window element AND NOT inside a Mantine portal. Mantine renders
|
||||
* dropdown menus (chat-list kebab), modals (delete-confirm), tooltips and
|
||||
* notifications into portals tagged with `data-portal="true"`; clicks on those
|
||||
* are part of operating the chat, so they must not collapse it.
|
||||
*/
|
||||
export function shouldCollapseOnOutsidePointer(
|
||||
target: EventTarget | null,
|
||||
windowEl: HTMLElement | null,
|
||||
): boolean {
|
||||
if (!windowEl) return false;
|
||||
if (!(target instanceof Element)) return false;
|
||||
// Inside the window itself -> not an "away" interaction (drag, resize, typing).
|
||||
if (windowEl.contains(target)) return false;
|
||||
// Inside a Mantine portal the chat owns (kebab menu, confirm modal, tooltip,
|
||||
// notifications). data-portal="true" reliably excludes all of them.
|
||||
if (target.closest("[data-portal]")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-vs-drag discrimination for the window header: a press whose pointer
|
||||
* moved less than `threshold` px on both axes between mousedown and mouseup is
|
||||
* treated as a click (which expands a collapsed window), not a drag (which
|
||||
* repositions it).
|
||||
*/
|
||||
export function isHeaderClick(
|
||||
downX: number,
|
||||
downY: number,
|
||||
upX: number,
|
||||
upY: number,
|
||||
threshold = 4,
|
||||
): boolean {
|
||||
return Math.abs(upX - downX) <= threshold && Math.abs(upY - downY) <= threshold;
|
||||
}
|
||||
Reference in New Issue
Block a user