feat(ai-chat): auto-collapse the chat window on page focus, expand on header (#42)
The floating chat window covered page content; you could only collapse it manually. Now it auto-collapses to its header (visual collapse only — ChatThread stays mounted so an in-flight stream isn't interrupted) when you interact with the page, and expands again from the header. - document mousedown listener in the CAPTURE phase, armed only when windowOpen && !minimized; collapses on a pointer-down outside the window. Guards: ignore clicks inside the window and inside any Mantine [data-portal] (the chat-list kebab menu + delete-confirm modal render in portals). - Header click expands: startDrag distinguishes click vs drag by a 4px threshold (minimizedRef avoids a stale closure); an expand-click doesn't persist geometry. - Reset minimized=false when the window opens (no sticky collapsed state). - a11y: when minimized, the title is the keyboard expand affordance (role=button, tabIndex, aria-label Expand, Enter/Space) — kept off the dragBar container so no role=button wraps the Minimize/Close buttons. - Pure helpers shouldCollapseOnOutsidePointer + isHeaderClick with vitest tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,12 @@
|
|||||||
display: none;
|
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 {
|
.dragBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import {
|
|||||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||||
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
|
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 { useClipboard } from "@/hooks/use-clipboard";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
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).
|
// History section starts collapsed (matches the former panel's behavior).
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [minimized, setMinimized] = 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);
|
const winRef = useRef<HTMLDivElement>(null);
|
||||||
// Live window geometry (position + size); initialized lazily on first open so
|
// Live window geometry (position + size); initialized lazily on first open so
|
||||||
@@ -254,8 +262,31 @@ export default function AiChatWindow() {
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!windowOpen) return;
|
if (!windowOpen) return;
|
||||||
setGeom((prev) => (prev ? clampGeom(prev) : computeInitialGeom()));
|
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]);
|
}, [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
|
// Persist the user's resize into state so it survives close/reopen. Skipped
|
||||||
// while minimized so the collapsed (auto) height is never captured. The
|
// while minimized so the collapsed (auto) height is never captured. The
|
||||||
// equality guard avoids an update loop.
|
// equality guard avoids an update loop.
|
||||||
@@ -303,10 +334,21 @@ export default function AiChatWindow() {
|
|||||||
el.style.top = `${nt}px`;
|
el.style.top = `${nt}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const up = (): void => {
|
const up = (ev: MouseEvent): void => {
|
||||||
document.removeEventListener("mousemove", move);
|
document.removeEventListener("mousemove", move);
|
||||||
document.removeEventListener("mouseup", up);
|
document.removeEventListener("mouseup", up);
|
||||||
document.body.style.userSelect = "";
|
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;
|
const el2 = winRef.current;
|
||||||
// Persist the final position back into state (preserving the size) so
|
// Persist the final position back into state (preserving the size) so
|
||||||
// re-renders keep it.
|
// re-renders keep it.
|
||||||
@@ -350,14 +392,40 @@ export default function AiChatWindow() {
|
|||||||
height: minimized ? undefined : geom.height,
|
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}>
|
<div className={classes.dragBar} onMouseDown={startDrag}>
|
||||||
<IconGripVertical
|
<IconGripVertical
|
||||||
size={14}
|
size={14}
|
||||||
color="var(--mantine-color-gray-4)"
|
color="var(--mantine-color-gray-4)"
|
||||||
style={{ flex: "none" }}
|
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
|
{/* Role badge for the active chat (emoji + name). Shown only when the
|
||||||
chat is bound to a role that still exists. */}
|
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