Files
gitmost/apps/client/src/features/ai-chat/utils/collapse-helpers.ts
claude code agent 227 f6e216cb87 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>
2026-06-20 23:45:43 +03:00

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