feat(ai-chat): auto-open last chat bound to the document (#191) #209
Reference in New Issue
Block a user
Delete Branch "feat/191-chat-doc-binding"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #191
Что сделано
При открытии плавающего окна ИИ-чата из шапки на странице документа теперь автоматически открывается последний чат, привязанный к этому документу. Привязка переиспользует существующее поле
ai_chats.page_id(без миграции): «привязанный чат» = самый свежий непросмотренный чат пользователя, созданный на этой странице — поэтому новый чат на странице автоматически становится привязанным.Сервер (read-only)
AiChatRepo.findLatestByPage(creatorId, workspaceId, pageId)— самый свежий поcreatedAt(тай-брейк поid),deletedAt IS NULL, скоуп user+workspace.POST /ai-chat/bound-chat(BoundChatDto) →{ chatId: string | null }. Доступа к странице не проверяем: матчатся только СВОИ чаты, чужойpageIdничего не раскрывает.Клиент
getBoundChat(pageId)в сервисе.useOpenAiChatForCurrentPage— резолв только на реальном переходе закрыто→открыто; вне страницы сохраняет текущий выбор; fail-soft → свежий чат; драфт/роль чистятся только при реальной смене чата.app-header.tsxвместо прямого тоггла. Бейдж авторства (ai-agent-badge.tsx) и окно (ai-chat-window.tsx) не трогались — диплинк идёт в обход привязки.Тесты
findLatestByPage(скоуп/фильтры/ордеринг/undefined); контроллерbound-chat(id владельца,nullдля чужого/несуществующего pageId).Проверки (зелёные)
pnpm --filter server exec tsc --noEmit -p tsconfig.json— OKpnpm --filter client exec tsc -b— OKБез миграции. Без node_modules в коммите.
🤖 Generated with Claude Code
Code review — PR #209: авто-открытие последнего чата, привязанного к документу (#191)
Вердикт: Request changes. Логика корректна и хорошо покрыта тестами, но есть один must-fix (отсутствует запись в CHANGELOG для пользовательского изменения поведения — нарушает строгую конвенцию репозитория) и заметная UX-регрессия задержки открытия окна, которую стоит починить до мержа.
Объём: дифф
develop…feat/191-chat-doc-binding(merge-base3ddc329b), 9 файлов, +401/−4. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход.Must fix before merge
[documentation] Добавить запись в CHANGELOG под
[Unreleased]/### Addedпро авто-открытие привязанного чата (#191) —CHANGELOG.md:11-13Репозиторий строго ведёт CHANGELOG по фичам: секция
[Unreleased]/### Addedуже документирует каждую сопоставимую пользовательскую AI-chat фичу по номеру issue (#183, #168, #180, #175, #166), и недавние мержи стабильно трогают CHANGELOG. PR меняет поведение видимое пользователю — кнопка AI-чата в шапке теперь не просто тоглит окно, а авто-открывает последний чат, привязанный к текущему документу, — но записи нет; релиз-ноты рассинхронизированы с поведением. Подтверждено:CHANGELOG.mdотсутствует в диффе. Fix: добавить буллет в### Added(или### Changed) секции[Unreleased]в стиле существующих AI-chat записей со ссылкой(#191)— привязка через существующийai_chats.page_id(без миграции), побеждает самый свежий собственный чат, fail-soft на свежий чат.[warning][regressions] Открывать окно до
await getBoundChat, чтобы первый клик оставался мгновенным —apps/client/src/features/ai-chat/hooks/use-open-ai-chat.ts:30-58Раньше кнопка в шапке открывала плавающее окно синхронно (чистая запись в атом, без I/O). Теперь
setWindowOpen(true)стоит ПОСЛЕresolved = await getBoundChat(pageId), поэтому на странице окно не появляется до возврата ответаPOST /ai-chat/bound-chat. На медленном соединении пользователь жмёт кнопку и ничего не происходит на время round-trip — читается как зависший контрол;catchловит ошибки, но не латентность. Fix: вызватьsetWindowOpen(true)заранее (до блокаif (pageId)), затем дождатьсяgetBoundChatи применить переключение чата (setActiveChatId/setDraft/setSelectedRoleId) уже после резолва.[suggestion][simplification] Убрать избыточный
setWindowOpen(true)в ветке «окно уже открыто» —apps/client/src/features/ai-chat/hooks/use-open-ai-chat.ts:31-35aiChatWindowOpenAtom— обычный boolean-атом; ветка выполняется только когдаwindowOpenужеtrue, так чтоsetWindowOpen(true)пишет то же значение и является no-op. Хук не импортирует локальныйminimized-state окна, так что развернуть свёрнутое окно этот вызов тоже не может — единственный эффект ветки — раннийreturn. Fix: заменитьif (windowOpen) { setWindowOpen(true); return; }наif (windowOpen) return;; финальныйsetWindowOpen(true)сохраняет использование сеттера, чистки не требуется.[suggestion][regressions] Подтвердить намеренность отказа от тогл-закрытия кнопкой в шапке —
apps/client/src/components/layouts/global/app-header.tsx:108Старая кнопка тоглила окно — повторный клик при открытом окне закрывал его. Новый
openAiChatтолько выставляетwindowOpen=true(раннийreturnв ветке «уже открыто» не закрывает), так что иконка в шапке больше не закрывает чат. Закрытие остаётся доступным через крестик окна (ai-chat-window.tsx:547), тупика нет, но это тихое изменение UX для тех, кто использовал иконку как тогл. Fix: если тогл-закрытие было фичей — закрывать окно в ранней ветке, когдаwindowOpenужеtrue; иначе оставить как есть и отметить намеренность в описании PR.[suggestion][simplification] (опц.) Вынести дублированный резолв current-page-id в общий хелпер —
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.ts:23-25Пара
useMatch("/s/:spaceSlug/p/:pageSlug")+extractPageSlugId(match?.params?.pageSlug)с многострочным комментарием скопирована дословно изai-chat-window.tsx(оба сайта в pathless layout-роуте, гдеuseParams()не видит:pageSlug); комментарий хука прямо говорит «Same route-match trick the window uses». Мелкая опциональная чистка (по кодуextractPageSlugIdповторяется ~25 раз черезuseParams(), паттерн не стандартизирован). Fix: маленькийuseCurrentPageId()вapps/client/src/features/ai-chat/hooks/, вызывать из обоих мест.Test coverage
Вся новая логика покрыта. Клиентский хук
use-open-ai-chat.test.tsx(135 строк) покрывает: резолв привязанного чата и переключение, отсутствие привязанного чата (fresh/null), off-page (без резолва), повторный клик при открытом окне (без переключения), сохранение драфта при совпадении чата, fail-soft на ошибку резолва, сброс выбранной роли при реальном переключении. Серверная сторона покрытаai-chat.repo.spec.ts(findLatestByPage) иai-chat.controller.bound-chat.spec.ts.Architecture & design (forward-looking, non-blocking)
Четвёртая точка «открыть чат + переключить активный чат» через прямую композицию атомов —
apps/client/src/features/ai-chat/{hooks/use-open-ai-chat.ts, ai-chat-window.tsx, ui/ai-agent-badge.tsx}Инвариант переключения (
setActiveChatId+setDraft("")+setSelectedRoleId(null), с опорой на render-фазовый reconcileruse-chat-session.ts) теперь переизобретён в четырёх сайтах, каждый со своим подмножеством эффектов:selectChat(полный набор +cancelPendingAdoption),startNewChat(то же с null),ai-agent-badge.tsxopenChat(безsetSelectedRoleId(null)), и новый хук (полный набор, под guardresolved !== activeChatId). Пропущенный сброс роли в badge — конкретный симптом дрейфа. Новый хук корректен; проблема forward-looking: будущие правки семантики переключения нужно помнить в до четырёх местах.вынести единый
openChatForPage/switchToChat(общий хук), через который идут все четыре сайта (включая badge)** (effort: m). Pros: одно определение «open + switch» (атом-записи + guard + сброс роли/драфта), убирает тихий дрейф badge, новая #191-логика становится тонким вызовом, будущие изменения семантики — в одном месте. Cons:selectChat/startNewChatдополнительно зовутcancelPendingAdoption, владелец которого —useChatSessionвнутри окна и недоступен из standalone-хука, так что чистая абстракция требует, чтобы disarm шёл через публичный атом-путь (reconciler уже обнуляетpendingNewChatRefпри реальном расхождении); in-window вызывающие сохранят свойcancelPendingAdoption. Нужна аккуратность, чтобы честно сохранить различие in-window/out-of-window.Семантика и индексация
ai_chats.page_id—apps/server/src/database/repos/ai-chat/ai-chat.repo.ts (findLatestByPage); миграция20260622T120000-ai-chat-page-origin.ts; индекс20260409T132415-ai-chat.tsКолонка
page_idвведена в20260622T120000явно как «Informational provenance shown in the chat-history list» — намеренно не несущая, без индекса,ON DELETE SET NULL. PR превращает её в реальный query-ключ:findLatestByPageфильтрует по(creator_id, workspace_id, page_id)+ORDER BY created_at DESC, id DESC LIMIT 1при каждом открытии окна на странице. Единственный индексidx_ai_chats_workspace_creatorна(workspace_id, creator_id, id)не покрывает этот предикат/сортировку;page_idне проиндексирован. Корректность delete/move уже верна (hard-delete страницы →page_idnull → привязка fail-soft отпадает; move сохраняет id → привязка живёт). Проблема: задокументированный контракт «informational» теперь противоречит использованию, а паттерн доступа не объявлен на уровне БД.схему оставить, но обновить документацию миграции/колонки, признав, что
page_idтеперь ещё и lookup-ключ (убрать формулировку «informational only»)** (effort: s). Pros: ноль стоимости записи, убирает рассинхрон контракта/использования, следующий читатель не введён в заблуждение. Cons: индекса нет, паттерн доступа остаётся необъявленным на уровне БД, опирается на малый размер таблицы.Code review (re-review) — PR #209: AI chat — кнопка в хедере авто-открывает чат, привязанный к документу (#191)
Вердикт: Approve. Прошлый блокер (отсутствующая запись в CHANGELOG для #191) закрыт; дельта — корректный рефактор порядка открытия окна без новых блокеров, регрессий и проблем авторизации.
Ре-ревью дельты
908b993b..99e4afdb(2 файлов, +16/−7). Аспекты: stability, conventions, documentation, test-coverage (параллельные ревьюеры + judge).Статус прошлых блокеров
CHANGELOG.md(секцияAdded) добавлен пункт «AI chat: header button auto-opens the chat bound to the current document» с описанием поведения и ссылкой(#191). Запись по существу: описаны выбор новейшего привязанного чата, переиспользованиеai_chats.page_idбез миграции и fail-soft на свежий чат.Must fix before merge
Нет.
Non-blocking
Нет.
Test coverage
Покрыто (логически без регрессии). Поведенческая логика дельты не изменилась —
setWindowOpen(true)лишь перенесён до round-tripgetBoundChat, чтобы окно открывалось мгновенно; ветки разрешения чата (pageIdесть/нет, bound/null,catch→ fail-soft, переключение только приresolved !== activeChatId) идентичны прежним. Early-returnif (windowOpen) return;эквивалентен прежнемуsetWindowOpen(true); return;(атом ужеtrue). Новой логики, требующей отдельных тестов, дельта не вносит.99e4afdb8cto669742832a669742832atoc64d7f315e