diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css index 71de2066..5758a018 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css @@ -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; diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 1b9012c5..2384f788 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -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(null); // Live window geometry (position + size); initialized lazily on first open so @@ -254,8 +262,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. @@ -303,10 +334,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. @@ -350,14 +392,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