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>
42 lines
1.6 KiB
TypeScript
42 lines
1.6 KiB
TypeScript
// 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;
|
|
}
|