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

This commit was merged in pull request #50.
This commit is contained in:
claude_code
2026-06-21 01:36:53 +03:00
4 changed files with 197 additions and 3 deletions

View File

@@ -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;

View File

@@ -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
@@ -257,8 +265,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.
@@ -306,10 +337,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.
@@ -353,14 +395,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. */}

View File

@@ -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);
});
});

View 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;
}