Add two new backlog documentation files: - ai-chat-collapse-on-page-focus.md describing auto‑collapse behavior for the AI chat window. - comments-panel-density.md outlining UI density improvements for the comments panel.
20 KiB
Авто-сворачивание AI-чата в заголовок при фокусе на странице, разворот по клику
Контекст (запрос)
Плавающее окно AI-чата (AiChatWindow) сейчас перекрывает контент страницы:
если открыть чат и начать читать/листать вики-страницу под ним, окно остаётся
во весь рост и закрывает таблицу/текст (см. скриншот: окно поверх «Аудио-тракт в
умных колонках»). Свернуть можно только вручную — кнопкой «—» (Minimize) в шапке.
Хотим, чтобы окно само сворачивалось в свою шапку, как только пользователь переключается на страницу (кликает мимо окна — в редактор/в контент), и разворачивалось обратно по клику на шапку. Тогда чат не мешает читать страницу, но остаётся под рукой одним кликом.
Важно: сворачивание — это именно визуальный коллапс (как нынешний Minimize), а не закрытие. Поток ответа агента не должен прерываться.
Как сейчас устроено (цепочка)
Всё во фронтенде, в одном компоненте окна:
apps/client/src/features/ai-chat/components/ai-chat-window.tsx
(+ его CSS ai-chat-window.module.css).
- Состояние «свёрнуто» уже есть:
const [minimized, setMinimized] = useState(false)— строка ~108. - Переключатель
toggleMinimize(строки ~319-321) просто инвертирует флаг; привязан к кнопке «—» (IconMinus) в шапке (строки ~366-374). - Визуальный коллапс уже реализован в CSS (
ai-chat-window.module.css):.minimized { height: auto !important; min-height: 0 !important; resize: none; }(строки ~40-44) — окно схлопывается до высоты шапки;.minimized .content { display: none; }(строки ~56-58) — тело (история + тред) скрывается, но не размонтируется:ChatThreadостаётся в DOM, поэтому идущий стрим/AbortControllerне обрывается (это явно описано в комментариях у.contentи вtoggleMinimize).- При
minimizedинлайноваяheightне задаётся (строка ~334), чтобы победила auto-высота из CSS; резайз-ручка скрыта (строки ~454-458).
- Шапка =
.dragBar(JSX строки ~338-385) сonMouseDown={startDrag}.startDrag(строки ~262-314) игнорирует нажатия на кнопках (if ((e.target).closest("button")) return;, строка ~264) — чтобы «—»/«×»/«+» не таскали окно.- В
mouseup(up, строки ~290-308) сохраняется итоговая позиция вgeom. - Клика-для-разворота сейчас нет: одиночный клик по шапке только инициирует перетаскивание, развернуть свёрнутое окно можно лишь повторным нажатием «—».
- Окно смонтировано глобально и плавает над всем:
<AiChatWindow />вapps/client/src/components/layouts/global/global-app-shell.tsx(строка ~159),position: fixed,z-index: 105(ниже оверлеев Mantine: modal=200, menu=300, notifications=400 — это нам важно, см. «Тонкие моменты»). - Композер автофокусится при монтировании треда (
autoFocusвchat-input.tsx) — это фокус внутри окна, не на странице.
Итого: «свёрнутый» вид готов. Нужно добавить два триггера: (1) авто-сворот при взаимодействии со страницей и (2) разворот по клику на шапку.
Решение (точечное, только клиент)
Файл: apps/client/src/features/ai-chat/components/ai-chat-window.tsx
(+ пара строк CSS, опционально + i18n-ключ).
Часть 1 — авто-сворачивание при взаимодействии со страницей
Слушаем mousedown/pointerdown на document (в capture-фазе), но только
когда окно открыто и ещё не свёрнуто. Если нажатие пришло вне окна и не
внутри портала Mantine — сворачиваем.
// Auto-collapse the window into its header as soon as the user interacts with
// anything outside it (clicks the page/editor). Active only while open and
// expanded. Capture phase so a child's stopPropagation can't hide the event.
useEffect(() => {
if (!windowOpen || minimized) return;
const onPointerDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
const el = winRef.current;
if (!el || !target) return;
// Inside the window itself -> not an "away" interaction.
if (el.contains(target)) return;
// Inside a Mantine portal the chat owns (kebab Menu dropdown, delete-confirm
// modal, the context-size Tooltip, notifications). Mantine's Portal sets
// data-portal="true" on its node, so this reliably excludes ALL of them.
if (target.closest("[data-portal]")) return;
setMinimized(true);
};
document.addEventListener("mousedown", onPointerDown, true);
return () => document.removeEventListener("mousedown", onPointerDown, true);
}, [windowOpen, minimized]);
Почему mousedown (а не focusin):
- Клик по не-фокусируемому элементу страницы (ячейка таблицы, обычный текст —
ровно случай со скриншота) фокус-событие не порождает, но это и есть «ушёл на
страницу».
mousedownловит любой клик.focusinпропустил бы такие клики. - Минус:
mousedownне ловит переход фокуса с клавиатуры (Tab в редактор). Если это нужно — добавить параллельноfocusin-слушатель с тем же гардом (см. «Открытые вопросы»). По умолчанию — только указатель, как и просит запрос («смена фокуса на страницу» = клик мимо окна).
Почему гард [data-portal] обязателен:
- Кебаб-меню списка чатов рендерит
Menu.Dropdownв портал (вне DOM окна) —conversation-list.tsxстроки ~123-149; удаление —modals.openConfirmModal(строка ~56), тоже портал. Без гарда клик по пункту «Rename»/«Delete» свернул бы чат прямо в момент выбора. Mantine на узле портала ставитdata-portal="true"(подтверждено вnode_modules/@mantine/core→Portal.cjs), поэтомуtarget.closest("[data-portal]")исключает их все (а заодно Tooltip размера контекста и нотификации — они тоже порталы).
Регистрация в useEffect с deps [windowOpen, minimized]: слушатель вешается
только когда windowOpen && !minimized, и снимается при сворачивании/закрытии —
не делаем лишней работы и не дёргаем setMinimized(true) повторно.
Часть 2 — разворот по клику на шапку
Нужно отличить клик по шапке (развернуть) от перетаскивания свёрнутой
плашки (она остаётся таскаемой). Нельзя просто навесить onClick на .dragBar:
браузер шлёт click и в конце драга (mousedown+mouseup на том же элементе), и
плашка разворачивалась бы после любого перетаскивания.
Решение — доработать существующий startDrag: запомнить стартовые координаты,
а в mouseup посчитать смещение; если оно ниже порога (≈4px) и окно сейчас
свёрнуто — развернуть.
const startDrag = useCallback((e: React.MouseEvent): void => {
if ((e.target as HTMLElement).closest("button")) return;
const el = winRef.current;
if (!el) return;
const sx = e.clientX;
const sy = e.clientY;
// ... (ol/ot + move() unchanged)
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. When minimized, a click on
// the header expands the window (drag still repositions the collapsed bar).
const moved =
Math.abs(ev.clientX - sx) > 4 || Math.abs(ev.clientY - sy) > 4;
if (!moved && minimizedRef.current) {
setMinimized(false);
return; // nothing to persist: position didn't change
}
// ... (persist geom as before)
};
// ...
}, []);
Подводный камень — stale closure: startDrag обёрнут в useCallback([]),
поэтому замыкает устаревший minimized. Два варианта:
- держать
minimizedRef = useRef(minimized)и синхронизировать его в эффекте (minimizedRef.current = minimized) — тогдаuseCallback([])остаётся (как в коде выше); рекомендуется, не пересоздаёт хендлер; - либо добавить
minimizedв depsuseCallback— проще, но пересоздаётstartDragна каждом тоггле (дёшево, но дёргаетonMouseDown-проп).
Кнопка «—» остаётся как явный тоггл (toggleMinimize уже инвертирует флаг), так
что развернуть можно и ей. Менять её не нужно.
Часть 3 (рекомендуется) — аффорданс и доступность шапки
- Курсор: в свёрнутом виде шапка кликабельна — заменить
grabнаpointer:/* ai-chat-window.module.css — hint that the collapsed header expands on click */ .minimized .dragBar { cursor: pointer; } - Клавиатура/скринридер:
.dragBar— этоdiv. В свёрнутом состоянии дать емуrole="button",tabIndex={0},aria-label={t("Expand")}и обработчик Enter/Space →setMinimized(false). Иначе развернуть без мыши нельзя.
Тонкие моменты / edge cases
- Стрим не прерывается. Авто-сворот выставляет
minimized=true—ChatThreadостаётся смонтированным (только.contentскрывается). Ответ агента достреливается в фоне; развернув шапку, пользователь видит результат. Это желаемое поведение (он специально ушёл читать страницу). - Автофокус композера при открытии. Открытие окна автофокусит textarea —
это
focusвнутри окна, а не внешнийmousedown, поэтому ложного немедленного сворота не будет. - Перетаскивание окна (mousedown по шапке) — это нажатие внутри
winRef.current, гардel.contains(target)его пропускает: drag не сворачивает. - Резайз нативной ручкой — mousedown тоже внутри окна, не сворачивает.
- Порталы дочерних компонентов (кебаб-меню, confirm-модалка, tooltip,
нотификации) исключены гардом
[data-portal]— клик по ним не сворачивает. Это ключевая причина не использовать «голый» contains-only outside-click. - Capture-фаза слушателя: ловим
mousedownдаже если кто-то на странице вызываетstopPropagationв bubble-фазе. На клики внутри окна/порталов не влияет (их отсекают гарды). - Повторный авто-сворот не происходит: при
minimizedслушатель снят (deps эффекта). Разворот по клику снова навешивает слушатель — цикл корректен. - Состояние при закрытии/открытии. Компонент при
!windowOpenвозвращаетnull, но не размонтируется, поэтомуminimizedпереживает закрытие. Желательно при каждом открытии показывать окно развёрнутым: добавитьsetMinimized(false)в эффект, срабатывающий на переходwindowOpen → true(можно в тот жеuseLayoutEffect, что вычисляет геометрию, строки ~238-241). См. «Открытые вопросы». - z-index/оверлеи. Окно (105) ниже modal/menu/notifications — поэтому
confirm-модалка удаления и кебаб-меню рисуются над окном; даже если бы чат
свернулся за ними, они продолжали бы работать. Но гард
[data-portal]всё равно не даёт сворачиваться при работе с ними. - Touch. Драг сейчас на mouse-событиях (десктоп-фича). Для единообразия
внешний слушатель можно сделать
pointerdownвместоmousedown(покроет тач), но тогда и порог-клик вupстоит считать на pointer-событиях. По умолчанию —mousedown, как у драга.
i18n
- Новые пользовательские строки — только через
t(...)и добавить ключ вapps/client/public/locales/en-US/translation.json(каталог ключ==значение). Достаточно"Expand"(дляaria-label/titleшапки в свёрнутом виде). В шапке уже естьt("Minimize"),t("Close"),t("New chat"). - Комментарии в коде — на английском (правило проекта).
Тесты
- Вынести чистые хелперы и покрыть Vitest:
shouldCollapseOnOutsidePointer(target, windowEl): boolean(windowEl.contains(target)+target.closest("[data-portal]")) —(внутри окна) → false,(в портале) → false,(на странице) → true.isHeaderClick(dx, dy, threshold=4): boolean— порог клик-vs-драг.
- Компонентный тест (
@testing-library/react): открыть окно → диспатчитьmousedownпоdocument.body→ окно получает класс.minimized; клик по.dragBar(без движения) в свёрнутом виде → класс снят. Проверить, чтоmousedownпо узлу сdata-portalсворота не вызывает. - Прогнать
pnpm --filter client lintиpnpm --filter client test.
Файлы к изменению
apps/client/src/features/ai-chat/components/ai-chat-window.tsx— внешнийmousedown-эффект (Часть 1); доработкаstartDrag+minimizedRef(Часть 2); опц.setMinimized(false)при открытии; a11y-атрибуты на.dragBar.apps/client/src/features/ai-chat/components/ai-chat-window.module.css— опц..minimized .dragBar { cursor: pointer; }.apps/client/public/locales/en-US/translation.json— ключ"Expand"(если добавляем aria/title).
Альтернативы / расширения (вне базового объёма)
useClickOutsideиз@mantine/hooksвместо ручного слушателя. Минус: порталы дочерних меню/модалок нужно явно передавать какnodesдля игнора, а они создаются динамически — ручной гард[data-portal]проще и надёжнее. Поэтому ручной слушатель предпочтительнее.- Учитывать клавиатурный фокус (
focusin) дополнительно кmousedown— если захотим сворачивать и при Tab в редактор. - Не сворачивать во время стрима — если решим, что во время генерации окно должно оставаться раскрытым (противоречит идее «ушёл читать страницу», поэтому по умолчанию сворачиваем всегда).
- Анимация коллапса/разворота (height/opacity transition) — косметика, можно
добавить позже в
.window/.content.
Принятые решения (базовый объём)
- Триггер авто-сворота — только клик (
mousedownв capture-фазе).focusinне добавляем: запрос — про переключение на страницу кликом, а клик по не-фокусируемому контенту (ячейка таблицы) фокус-событие не даёт. - При каждом открытии окна показываем его развёрнутым —
setMinimized(false)на переходwindowOpen → true. Свёрнутое состояние не «залипает» между сессиями открытия. - Во время стрима сворачиваем как обычно. Поток не прерывается (
ChatThreadостаётся смонтированным), результат виден после разворота — это и есть смысл «ушёл читать страницу». - Клавиатурный разворот шапки входит в базовый объём — в свёрнутом виде
.dragBarполучаетrole="button",tabIndex={0},aria-label={t("Expand")}и обработку Enter/Space. Доступность без мыши обязательна.