Commit Graph

914 Commits

Author SHA1 Message Date
vvzvlad d608311cae Merge pull request 'fix(ui/toasts): глобальная видимость (тон+рамка+тень) + перенос в top-center без перекрытия (#517)' (#528) from fix/517-toasts into develop
Reviewed-on: #528
2026-07-12 04:06:41 +03:00
vvzvlad 17e3b3d882 Merge pull request 'fix(converter): block-escape + attribute contract + dedup mirrors (#493)' (#514) from feat/493-converter into develop
Reviewed-on: #514
2026-07-12 04:04:24 +03:00
agent_coder 401d62119c Merge remote-tracking branch 'gitea/develop' into feat/493-converter
# Conflicts:
#	CHANGELOG.md
2026-07-12 03:23:14 +03:00
agent_coder 51260793c0 fix(ui/toasts): глобальная видимость тостов + перенос в top-center (#517)
Тост-уведомления Mantine сливались с фоном: у бесцветных тостов фон
карточки == var(--mantine-color-body) (белый, как страница) при слабой
тени, поэтому на белых страницах у карточки не было видимого края. Плюс
тосты всплывали снизу по центру и перекрывали контент.

Чиним глобально, без правок в 213 местах вызова:

- notification-overrides.css: каждому тосту даём тонированный по типу фон,
  рамку с контрастом WCAG >= 3:1 и усиленную тень (shadow-xl). Селектор
  [data-mantine-color-scheme=...] .mantine-Notification-root имеет
  специфичность (0,2,0) и стабильно бьёт правила Mantine (0,1,0) (у Mantine
  атрибут схемы обёрнут в :where()) — независимо от порядка стилей. Тон/рамка
  идут от --notification-color (определён на том же элементе), поэтому следуют
  типу тоста и покрывают loading/импортный тост (полосы-акцента нет — несут
  тон+рамка+тень+цветной спиннер). Обе темы; текст-с-заголовком поднят до
  gray-7 ради AA-контраста на тонированном фоне.

- main.tsx: position bottom-center -> top-center. Вертикальное смещение
  контейнера ниже верхней хромы делаем НЕ инлайн-стилем, а CSS-правилом со
  скоупом по позиции: Mantine рендерит все шесть позиционных контейнеров
  одновременно, и корневой style-проп ушёл бы во все шесть — нижним (bottom:16)
  добавился бы top:96 → position:fixed + оба края + height:auto растянули бы их
  на весь вьюпорт; у корня нет pointer-events:none/фона → прозрачные оверлеи
  z-10000 перехватывали бы клики по всей странице.

- notification-overrides.css: .mantine-Notifications-root[data-position^='top']
  { top:96px } (шапка 45 + опц. тулбар 45 + зазор). Скоуп ^='top' смещает
  только верхние контейнеры; нижние остаются height:0 и кликов не перехватывают.
  Специфичность (0,2,0) бьёт mantine top:16px (0,1,0), тост z-10000 стоит ниже
  шапки/тулбара (z-99) и их не перекрывает.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:14:38 +03:00
agent_coder 6b27ff0652 feat(ai,mcp): идентичность прогона реиндекса + брендированные PageId/SlugId (#495 item 14)
Аспект A — идентичность прогона реиндекса эмбеддингов.
У статуса/поллинга реиндекса не было идентичности КОНКРЕТНОГО прогона, из-за
чего класс багов «это тот же прогон или новый?» чинили дважды. Теперь каждый
прогон получает свой runId (crypto.randomUUID в start()), он хранится в Redis-
хэше рядом с total/done/startedAt и возвращается в ReindexProgress. Эндпоинт
статуса (getMasked -> MaskedAiSettings) отдаёт runId и reindexStartedAt. Клиент
кеит поллинг на (runId, startedAt): смена runId = НОВЫЙ прогон (сбрасываем
залатанное per-run состояние поллинга), тот же runId = тот же прогон. Всё
best-effort/косметика как и остальной код прогресса: пустой/отсутствующий runId
деградирует мягко и никогда не ломает реиндекс.

Аспект B — брендированные PageId/SlugId в MCP-клиенте + валидация формата в
серверных DTO (семейство инцидентов #435: двойная идентичность страницы —
внутренний id и slugId — гонялась как голая строка и молча путалась).
- packages/mcp/src/lib/page-id.ts: номинальные типы PageId/SlugId + валидирующие
  конструкторы asPageId/asSlugId и гварды isPageId/isSlugId (формат UUID и
  10-символьного slugId). PageId протянут через единственный узел канонизации и
  записи: resolvePageId() теперь возвращает PageId, а ключ per-page лока
  (withPageLock) и точки записи в collab (mutatePageContent/replacePageContent/
  updatePageContentRealtime) требуют бренд — сырой slugId/непроверенный id больше
  не проходит проверку типов (инвариант #260 «resolve-then-lock» теперь на уровне
  компилятора). Публичный вход методов остаётся строкой (это legitimно UUID ИЛИ
  slugId), брендируется каноническое значение.
- apps/server core/page/dto: PageIdDto.pageId получает валидацию формата
  (@Matches, UUID или 10-символьный slugId), так что кривая/подменённая
  идентичность отклоняется на границе, а не падает в repo голой строкой.

Тесты (все зелёные, с mutation-verify каждого):
- server: getMasked отдаёт runId/reindexStartedAt; стор пишет/читает runId и
  мягко деградирует его до ''; DTO отклоняет кривой pageId/slugId и принимает
  валидный.
- client: чистый хелпер reindexRunKey/isNewReindexRun (кеинг поллинга на runId).
- mcp: конструкторы/гварды PageId/SlugId (валидация формата).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 173f35e473 fix(editor): защитить чтение приватных стеков y-undo в тулбаре
canUndo/canRedo в use-toolbar-state читали приватные внутренности y-undo
(undoManager.undoStack.length / redoStack.length). Апгрейд yjs / y-undo,
переименовавший или перестроивший эти поля, тихо сломал бы состояние кнопок
undo/redo (или упал бы на .length у undefined) без единой ошибки.

Оставляем дешёвое чтение длины стеков (сознательно не используем дорогой
editor.can().undo()/.redo(), который делает dry-run на каждый keystroke,
см. комментарий в файле), но теперь feature-detect: доверяем стекам только
если это реально массивы, иначе откатываемся на безопасный дефолт
(prosemirror-history undoDepth/redoDepth -> 0). Логика вынесена в чистую
функцию yHistoryAvailability.

Добавлен pin-test, фиксирующий текущую форму библиотеки: реальный
Y.UndoManager по-прежнему отдаёт undoStack/redoStack массивами. Апгрейд,
меняющий контракт, упадёт громко в тесте, а не тихо в UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 696b96ac18 fix(client): авто-перезагрузка чанков в окне 5 минут вместо one-shot флага
При ChunkLoadError граница перезагружала страницу один раз, гейтируя
булевым флагом в sessionStorage, который никогда не сбрасывался. Из-за
этого ВТОРОЙ деплой за время жизни вкладки не давал авто-восстановления:
пользователь застревал на битом чанке без перезагрузки.

Заменяю one-shot флаг на счётчик по временному окну: не более одной
авто-перезагрузки за 5 минут. В sessionStorage храню метку времени
последней перезагрузки; на ChunkLoadError перезагружаемся только если
прошлая была раньше окна (или её не было), иначе проваливаемся в ручной
UI без перезагрузки. Это восстанавливает работу через несколько деплоев,
но не даёт бесконечного цикла при навсегда битом lazy-чанке (сброс флага
после успешного маунта отвергнут: оболочка монтируется, чанк 404 —
и цикл).

Решение об окне вынесено в чистый хелпер shouldAutoReload(now,
lastReloadAt, windowMs) и покрыто юнит-тестами: никогда-не-грузили →
можно; 6 минут назад → можно; 1 минуту назад → нельзя.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder c0c44fddb9 fix(security): client-vitals — whitelist route/attr на анонимном эндпоинте
POST /api/telemetry/vitals анонимный, а route/attr не сверялись со словарём:
любой мог писать свободный текст в client_metrics (высокая кардинальность,
инъекция текста/PII/разметки).
- route: экспортировал полный словарь шаблонов из клиентского route-template.ts
  (KNOWN_ROUTE_TEMPLATES — канонический источник), сервер валидирует по зеркалу
  ALLOWED_ROUTE_TEMPLATES: нет в словаре → drop (null), событие остаётся.
- attr: это web-vitals attribution target (CSS-селектор), а не enum — ограничил
  консервативным charset CSS-селектора; всё вне набора → drop.
Клиентский self-consistency тест: templateRoute выдаёт ТОЛЬКО значения из
словаря (иначе легитимные метрики отбрасывались бы). Серверные тесты: raw-путь
и инъекция в route отброшены, PII/разметка в attr отброшены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder f1cffc2d0f fix(security): share-alias availability не отдаёт currentPageId
Проба доступности алиаса возвращала currentPageId — id страницы, на которую
алиас уже указывает — ЛЮБОМУ аутентифицированному участнику воркспейса без
проверки прав на просмотр этой страницы. Перебором имён алиасов можно было
смапить их на id страниц, к которым доступа нет.

Теперь checkAvailability отдаёт только {alias, valid, available}. Бита
taken/free достаточно для пробы; заголовок целевой страницы всплывает лишь
ПОСЛЕ реальной попытки setAlias (путь 409 ALIAS_REASSIGN_REQUIRED), который
проверяет права. Клиент currentPageId нигде не использовал — убран из типа,
стейта и теста. Серверный спек утверждает отсутствие currentPageId в ответе.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:53:44 +03:00
agent_coder bb9a6fd765 chore(ai-chat): guard-пороги в env + единый маркер дегенерации
Три хвоста детектора петель (#444):
- Пороги детектора дегенерации теперь конфигурируются из env (по образцу
  AI_CHAT_FINAL_STEP_LOCKDOWN): AI_CHAT_DEGENERATION_REPEATED_LINES /
  _PERIOD_MAX_LEN / _PERIOD_MIN_REPEATS / _CHECK_STEP. Резолвер читает сырую
  строку (пусто = unset → компилируемый дефолт), требует ≥1, иначе безопасный
  откат к дефолту (0/отриц. сломал бы детектор). Оператор перенастраивает
  анти-babble-guard без редеплоя.
- Единый маркер: при дегенерации live-стрим показывает нейтральное «Response
  stopped.» (клиент не отличает от ручного Stop), а персист-баннер после refetch
  падал в дженерик. Классифицировал OUTPUT_DEGENERATION_ERROR на клиенте под тот
  же заголовок «Response stopped.» + деталь про петлю — live и refetch больше не
  расходятся.
- STEP_LIMIT_NO_ANSWER_MARKER: вместо хардкодной русской строки в content —
  локаль-нейтральная английская (базовая локаль = i18n-ключ этого репо, читаема
  моделью на реплее); не-русские юзеры больше не видят русский текст.

Тесты: env-пороги гоняются против реальных форм повторов с mutation-проверкой
(поднятый checkStep глушит burst; сниженный repeatedLines триггерит короткий
ран); клиентская классификация — новый кейс в error-message.test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:53:44 +03:00
agent_coder 0503e8b4b1 fix(ai-chat): W1 — клиент читает 409-поле activeRunId (было runId=undefined)
Ревью волны 1 (agent_vscode): сервер эмитит id текущего рана в activeRunId
на обоих 409-бранчах (SUPERSEDE_TARGET_MISMATCH, A_RUN_ALREADY_ACTIVE), а
клиентский read409 читал runId → SUPERSEDE_MISMATCH{currentRunId} всегда
undefined: быстрый supersede-хинт мёртв в проде, а клиентские тесты
ложно-зелёные (мокали поле runId, которого сервер не шлёт).

- read409 читает activeRunId (undefined-safe); оба мока — на реальную форму
  + ассерт на усвоенный currentRunId (mutation: revert→runId краснит тесты).
- S4 (groundwork): activeRunId из A_RUN_ALREADY_ACTIVE поглощается в runFact
  (to(), без бампа эпохи/ownership — инварианты #488 целы). Полная обвязка
  supersede чужой вкладки из фазы error требует расширения gate в sendNow —
  отдельным follow-up; сейчас это безопасная заготовка, не рабочая фича.
- spec.md: 2 строки контракт-таблицы приведены к activeRunId.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:23:10 +03:00
agent_coder e1b8ef5b8b fix(converter): block-escape начала параграфа — закрытие класса потерь данных
Строка параграфа, начинающаяся с блочного триггера (`#`/`-`/`*`/`+`/`>`,
упорядоченного `N.`/`N)`, фенса ```/~~~, таблицы `|` или тематического
разрыва `---`/`***`/`___`), на round-trip doc->markdown->doc молча
превращалась в heading/list/quote/code block/table/horizontalRule. Худший
случай — тематический разрыв: horizontalRule не несёт текста, и строка
теряла его целиком.

Сериализатор параграфа теперь backslash-экранирует ведущий блочный триггер
(escapeLeadingBlockTrigger): экранируется только ПЕРВЫЙ значащий символ,
токенизатор CommonMark декодирует `\` обратно в литерал И снимает блочную
интерпретацию, так что строка round-trip'ится байт-в-байт как параграф.
Emphasis `**x**`, inline-code и обычная проза триггерами не являются и не
трогаются (нет мусорных backslash).

Класс раньше не чинили, а ОБХОДИЛИ; обход убран у обоих потребителей:
- клиентский мост (gitmost-recording.ts) больше не подставляет ZWSP-хак;
- генеративный корпус (text-arbitraries.ts) снял самоцензуру — добавлен
  blockTriggerLeadRunArb, параграф теперь МОЖЕТ открываться триггером, и
  P1/P2/P3 сами доказывают закрытие класса.

Пины на каждый триггер — детерминированные round-trip через реальный
конвертер (gitmost-transcript-neutralization.test.ts). Обновлён
документировавший старую потерю gap-тест (spec 13).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:43:24 +03:00
agent_coder 080dd82023 fix(client): ре-ревью #509 — 5 пунктов Do-листа (стабильность/регрессии) (#488)
1 [stability] Позитивные attach-исходы гвардятся по ИСХОДНОЙ фазе. Одного epoch-
фильтра мало: POLL_TERMINAL использует to() (epoch не инкрементит) и не шлёт
abortAttach, поэтому медленный GET, вернувший live 2xx уже ПОСЛЕ того как армленный
poll увёл машину в idle, воскрешал осевший ран в фантомный streaming. RECONNECT_
ATTACHED теперь bail'ит если фаза != reconnecting; ATTACH_LIVE/ATTACH_NONE — если
!= attaching. Тест на гонку (POLL_TERMINAL до RECONNECT_ATTACHED → idle) + mutation.

2 [regressions] ownership сбрасывается в "local" на ВСЕХ терминальных переходах
(FINISH_CLEAN/ABORT/ERROR, POLL_TERMINAL, RUN_FACT{null}→idle, honor-in-stopping,
disconnect→idle). Иначе observer-attach + очередь + clean-финиш → idle, но
ownership навсегда observer → «Send now» скрыт при свободном композере. Безопасно
для I2: рантайм захватывает wasObserver из machineRef ДО dispatch. Тест + mutation.

3 [coverage] Тест happy-path CAS-supersede: SUPERSEDE_READY-dispatch (200-исход
transport.fetch) раньше НЕ исполнялся ни в одном тесте — риск залипания в
superseding на весь стрим B. Тест вводит в superseding, гонит A.onFinish→B, затем
POST 200 → SUPERSEDE_READY → streaming, проверяет повторный supersede (не залип).
Сиблинги: 409 SUPERSEDE_TARGET_MISMATCH → getRun/verify; plain-409
A_RUN_ALREADY_ACTIVE → классифицированный баннер. Mutation (no-op READY → красный).

4 [regressions] Inactivity-бэкстоп для poll, армленного в stopping. STOP_REQUESTED
армит poll и входит в stopping, но idle-cap покрывал только polling/reconnecting →
observer-стоп без SDK-стрима и без серверного терминала поллил БД вечно. Добавлен
stopping в фаза-чек эффекта + переход POLL_IDLE_CAP: stopping→idle+disarm (НЕ
stalled — Stop уже нажат). FSM + компонент-тест (idle-cap→disarm) + mutation.

5 [docs] Счёт §2 «Net»: 8→FSM (#1-6,#11,#13) + 3 deleted + reconnectTimerRef
(effect-owned) + mountedRef (retained) = 13; attachAbortRef вне набора #1-13.

Не трогал DROP-блок (мёртвый FINISH_* в superseding, ErrorKind.kind, неиспользуемые
enum-варианты, epochRef-зеркало). Всё прошлое цело (disconnect-first, epoch, honor-
in-stopping, render-gate, supersede). Полный ai-chat 35 файлов / 388 / 0; tsc 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:07:02 +03:00
agent_coder 803bbd40b4 fix(client): фаза FSM решает баннер — reconnect не маскируется остаточным error (#488)
Браузерный QA: маршрутизация disconnect-first работает (лесенка реально стартует),
но пользователь всё равно видел терминальный «Lost connection… reload», а не
«reconnecting… (N/5)».

Трасса (подтверждена по коду): рендер `{errorView ? <ChatErrorAlert/> : phase ===
"reconnecting" ? ...}` даёт errorView ПРИОРИТЕТ над recovery-фазой. errorView =
`error && describeChatError(...)`, где `error` — из useChat. Реальный ai@6 при
isError выставляет useChat `error`, а дроп ВСЕГДА isError+isDisconnect → после
починки маршрутизации FSM уходит в `reconnecting`, но `error` остаётся выставленным
→ errorView перекрывает reconnect-баннер. Тот же класс вакуум-мока, что и с
isDisconnect, но на поле `error` (мок хардкодил error:null → в тестах маски не было).

Фикс рендера: фаза FSM — источник истины. Терминальный errorView показывается
ТОЛЬКО когда FSM реально терминален: `showError = errorView && phase === "error"`.
В recovery-фазах (reconnecting/polling/stalled/superseding/stopping) выигрывает
recovery-баннер (или контент стрима). Классифицированные 409 (#487) целы: supersede/
gate-409 ставят FSM в error(kind) → errorView показывается там, где должен.

Мок useChat сделан реалистичным СИСТЕМНО: добавлено поле h.state.error, мок его
возвращает; при любом isError-финише (дроп или провайдерская ошибка) тест ставит
error в реальную форму, зеркаля связку SDK. MUTATION-VERIFY: откат рендер-гейта
(errorView-first) → 8 reconnect/stalled-тестов краснеют (баннер маскируется);
с гейтом — зелёные. Плюс отдельный тест «reconnect-баннер виден, не замаскирован».

Всё прошлое цело: disconnect-first, epoch-штамп, honor-in-stopping, supersede-
исходы, stalled/no-poll. Полный ai-chat 35 файлов / 378 / 0; tsc ai-chat 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:55:39 +03:00
agent_coder c7a38e274e fix(client): реальный обрыв SSE → reconnect-лесенка, не терминальный error (#488)
Браузерный QA поймал баг, который юнит-тесты пропускали из-за вакуумной SDK-формы.

Премиса (подтверждена по ai@6.0.207 AbstractChat.makeRequest, catch ~13763): при
сетевом дропе SDK ставит isError=true БЕЗУСЛОВНО, а isError=true → isDisconnect=true
ставится РЯДОМ (только для fetch/network TypeError). Т.е. реальный обрыв ВСЕГДА
даёт { isError:true, isDisconnect:true }; форма { isDisconnect:true, isError:false }
SDK'ом НЕ эмитится.

Баг: в onFinish проверка `if (isError) return` стояла РАНЬШЕ ветки isDisconnect →
реальный дроп уходил в терминальный error-баннер, а FINISH_DISCONNECT (единственный
вход в reconnect-лесенку) не диспатчился НИКОГДА. Сценарии 1/2 (commit 2 «обрыв до
первого кадра» и commit 3 «два обрыва») в браузере не работали.

Фикс: маршрутизация по disconnect ПЕРВЫМ: isDisconnect → FINISH_DISCONNECT
(reconnect); НЕ-disconnect error (isError && !isDisconnect, напр. провайдерский 500)
→ FINISH_ERROR (терминал); затем isAbort; затем clean. Порядок веток supersede-
блока тоже disconnect-first (для консистентности; там всё равно всё дропается I1).

Инварианты сохранены: epoch-штамп turnEpochRef на всех FINISH_*; honor-in-stopping
в редьюсере честит ЛЮБОЙ финиш в фазе stopping → на пути Stop дроп-финиш уходит в
idle, а не в ложный reconnect (новый тест). F1 supersede-drop чужого поколения цел.

Тесты сделаны НЕ вакуумными: дроп подаётся реальной формой { isError:true,
isDisconnect:true }, терминальная ошибка — { isError:true, isDisconnect:false }.
MUTATION-VERIFY: багованный isError-first порядок → 6 reconnect-тестов краснеют
(нет баннера «reconnecting»); с фиксом зелены. Полный ai-chat 35 файлов / 377 / 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:37:07 +03:00
agent_coder 1685b32333 fix(client): ре-ревью F1 — правка задетых Stop и supersede-таймингов (#488)
MEDIUM — эпоха-штамп из F1 сломал выход из `stopping` для локального Stop.
STOP_REQUESTED бампит epoch (E1→E2), а onFinish аборчиваемого стрима стампится
ПРЕД-стоп-эпохой E1 (start-эпоха стрима), поэтому фильтр I1 дропал FINISH_ABORT и
машина зависала в `stopping` навсегда (idle-cap покрывает только polling/
reconnecting). Фикс в редьюсере: ЛЮБОЙ finish (`FINISH_*`/`STREAM_INCOMPLETE`) в
фазе `stopping` честится и выводит `stopping→idle` МИНУЯ epoch-фильтр — у обычного
Stop нет преемника, финиш аборта и есть ожидаемое завершение (I4). Для `superseding`
фильтр сохранён (это и есть F1-drop). Тест переписан на ПРЕД-стоп-эпоху E1 (реальная
проводка); MUTATION-VERIFY: снятие honor-in-stopping → зависание → красный.

LOW-1 — supersede терял B, если A уже settled, а statusRef ещё «streaming».
Ливнесс в sendNow теперь берётся из ФАЗЫ FSM (machineRef, обновляется onFinish'ем
СИНХРОННО), а не из отрендеренного statusRef: settled-A (фаза idle) → B шлётся
немедленно, без аборта мёртвого стрима и залипания в `superseding`. statusRef
удалён (больше не нужен). Тест на под-кадровое окно.

LOW-2 — двойной «Send now» в окне аборта A перезаписывал pendingSupersedeTextRef.
Второй клик при уже летящем supersede (pendingText взведён / фаза `superseding`) —
NO-OP: сообщение остаётся в очереди, ничего не теряется/не перетирается. Тест.

Тесты: FSM 36 + chat-thread 39 (+error 26 +adopt 16) = 117 зелёных; tsc ai-chat 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 08:02:04 +03:00
agent_coder 4b78e336a2 fix(client): внутреннее ревью миграции FSM — F1..F4 (#488)
F1 [correctness] — supersede: перекрытие стримов рушило свежий run.
ai@6 AbstractChat.makeRequest в finally читает и обнуляет общий activeResponse,
поэтому параллельные стрим-A и стрим-B корёжат друг друга, а незастемпленный
onFinish мёртвого A уводил ЖИВОЙ новый run в ложный reconnect / сбрасывал runFact.
Фикс: (1) FINISH_*/STREAM_INCOMPLETE штампуются per-stream generation (turnEpochRef,
взводится в момент старта стрима) → фильтр I1 отбрасывает финиш чужого поколения;
(2) sendNow-supersede АБОРТИТ A и стартует B только из onFinish A (микротаск,
после того как finally A обнулил activeResponse) — гарантия отсутствия перекрытия.
Тест на поздний isDisconnect A после SUPERSEDE_REQUESTED: машина НЕ уходит в
reconnect, B отправлен. MUTATION-VERIFY: снятие epoch-штампа у FINISH_DISCONNECT →
тест краснеет («Connection lost — reconnecting»).

F2 [correctness] — гонка mount getRun→ATTACH_START с локальным send. Редьюсер
ATTACH_START теперь игнорирует любую не-idle фазу, поэтому поздний резолв getRun не
перехватывает начавшийся локальный турн в observer-attach. Тест на гонку.

F3 [ghost feature] — RUN_SUPERSEDED объявлен+покрыт тестом, но НИКОГДА не
диспатчился. Удалён (событие+обработчик+тест+postRun-reason observer-follow) как
сознательный scope-cut: наблюдатель убитого supersede-рана и так следует за новым
через деградированный поллинг (свежие строки истории, независимо от runId).

F4 [hygiene] — мёртвые события. STREAM_START подключён (первый ассистент-фрейм
локального турна: sending→streaming, спека↔код совпали). RECONNECT_BEGIN и
POLL_ACTIVITY удалены (не диспатчились). Множество событий редьюсера = множеству
диспатчащихся; редьюсер тотален.

Тесты: FSM 35 + chat-thread 37 (+error 26 +adopt 16) = 114 зелёных; tsc ai-chat 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 08:02:04 +03:00
agent_coder 77e20ecb41 fix(client): миграция chat-thread на FSM — reconnect×N, stalled, supersede (#488)
Полная миграция chat-thread.tsx на автомат run-fsm: 13 ref-флагов жизненного
цикла resume/reconnect/poll/ownership УБРАНЫ (карта ref'ов в run-fsm.spec.md,
колонка pending пуста). Коммиты 3/4/5 приезжают одной атомарной миграцией —
три фикса делят одну модель состояний (раздельные коммиты давали бы несобираемые
промежуточные состояния, что противоречит смыслу единого автомата).

Коммит 3 — повторные циклы reconnect: attached→reconnecting разрешён многократно.
Различие «live-follow (лестница reconnect) vs mount-resume» вынесено в ctx-поле
liveFollow (НЕ новый ref — это и есть смысл FSM): live-follow-обрыв перезаходит в
лестницу (сброс счётчика после успешного re-attach), mount-resume-обрыв уходит в
poll. Тест «два обрыва подряд → два цикла».

Коммит 4 — (a) polling→stalled по idle-капу (баннер+Retry вместо тихого
«вечно-полуготового»); кап переехал в тред (idleCapTimerRef, effect-owned, не
флаг), окно теперь тупо поллит по armed-флагу. (b) resume армится ТОЛЬКО при
серверном подтверждении активного рана: streaming-tail (статус) или POST /run для
user-tail — чат без активного рана больше не порождает ~240 req/10мин. Тесты:
stalled-баннер; user-tail с/без активного рана.

Коммит 5 (supersede) — удалены SUPERSEDE_RETRY_DELAYS_MS/isRunAlreadyActive/
supersedeRetryRef (клиентская лестница ретраев). «Прервать и отправить» идёт через
FSM superseding → POST /stream {supersede:{runId}} (runId из start-метаданных,
extractRunId). Транспорт разбирает CAS-исход: ok→SUPERSEDE_READY (новый стрим),
409 MISMATCH→verify через /run, TIMEOUT/INVALID→классифицированная ошибка без
авто-ретрая; голый 409 A_RUN_ALREADY_ACTIVE→RUN_ALREADY_ACTIVE. pendingSupersedeRef
(send-плумбинг data) — единственная замена трёх удалённых one-shot'ов.

Инвариант epoch (I1) гейтит каждый command-outcome (attach/reconnect/supersede/
postRun): устаревшее поколение колбэка отбрасывается редьюсером; DISPOSE на unmount
инкрементит epoch. mountedRef оставлен как React-liveness (ортогонален lifecycle).

Тесты: FSM 37 переходов; chat-thread 35 (переписан на FSM-переходы); все зелёные.
E2E (реальный SSE/reconnect/supersede через редиплой) — на staging QA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 08:02:04 +03:00
agent_coder d533daa45f fix(client): обрыв SSE до первого кадра ассистента → ран подхватывается (#488)
Коммит 2. Раньше вход в reconnect требовал `message?.role === "assistant"`, и
обрыв в setup-фазе (до первого кадра ассистента, включая сборку MCP-тулсета с
дедлайном до 60 c) не давал НИ реконнекта, НИ поллинга — а detached-ран
продолжал писать в страницы (тихая дыра целостности).

Правка: вход в reconnect по РАН-ФАКТУ (активный detached-ран), а не по наличию
assistant-сообщения. В autonomous-режиме ран активен весь ход, поэтому здесь
сигнал run-факта — сам autonomousRunsEnabled; более богатый серверный run-факт
(POST /run / runId из start-метаданных) смоделирован и покрыт тестами в FSM
(run-fsm.ts FINISH_DISCONNECT по ctx.runFact) и приезжает с полной миграцией
компонента. При отсутствии assistant-строки reconnect идёт БЕЗ strip/anchor
(простой live-attach — на экране нечего перестраивать).

Тест: «обрыв до первого кадра → баннер reconnect + resumeStream + attach без
anchor», плюс FSM-переход (уже зелёный в коммите 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 08:02:04 +03:00
agent_coder ba58493909 fix(client): классификация 409-кодов + run-факт плумбинг (#488)
Потребляет реальный контракт #487 на клиенте (без выдумывания кодов):
- error-message.ts: ветки для 409 A_RUN_ALREADY_ACTIVE / SUPERSEDE_TARGET_MISMATCH
  / SUPERSEDE_TIMEOUT / SUPERSEDE_INVALID — человеческие тексты СТРОГО ДО generic-
  веток по статусу (иначе юзер видит сырой JSON {"code":"A_RUN_ALREADY_ACTIVE"});
- extractRunId(message): чтение runId из start-метаданных (зеркало
  extractServerChatId) — live-обновление run-факта для FSM;
- getRun(chatId): POST /ai-chat/run — first-class run-факт с сервера (init на
  маунте + verify после supersede-mismatch).

Плумбинг под FSM-обвязку коммитов 2–5. Тесты: классификатор (все 4 кода + order-
guard), extractRunId.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 08:02:04 +03:00
agent_coder 947796adef refactor(client): FSM skeleton + spec для run-lifecycle (#488)
Заменяет зоопарк из ~26 useRef-флагов в chat-thread.tsx на один чистый
редьюсер с перечислимыми переходами (event × state → next state — впервые
юнит-тестируемо напрямую).

Коммит 1 из 5. Содержит СПЕКУ (пишется первой, входит в PR) и каркас:
- run-fsm.spec.md: таблица «событие × состояние», карта всех ref'ов
  → {состояние | контекст | данные}, протокол run-факта, список инвариантов;
- run-fsm.ts: чистый reduce(machine, event) → machine с epoch-инвариантом (I1),
  состояниями idle|sending|streaming|attaching|reconnecting|polling|stalled|
  stopping|superseding|error, ownership как ПОЛЕ контекста (I2), run-фактом
  как first-class (I3), выходом из stopping по данным (I4), dispose-протоколом
  (I5) и слоем command-эффектов (attach/postStream/postRun/stop/supersede);
- run-fsm.test.ts: 31 тест переходов, включая поведение коммитов 2–5 как
  переходы автомата (reconnect по run-факту; повторные циклы reconnect;
  polling→stalled; supersede CAS-исходы; фильтрация позднего колбэка по epoch).

8 зафиксированных решений реализованы; epoch-инвариант неотключаем.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 08:02:04 +03:00
agent_coder de8f9c804c fix(client): flushNext в onFinish гейтится mountedRef (#486)
Финальный onFinish->flushNext() не проверял live-mount флаг. Чистый onFinish
может прийти ПОСЛЕ анмаунта треда (New-chat / переключение чата мид-стрим —
асинхронные attach/resume оседают поздно): flush дергал очередь и re-POST'ил
сообщение из брошенного треда — «призрачные» отправки/чаты-призраки. Остальные
обращения к очереди уже гейтятся mountedRef; закрываем последнюю дыру.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:19:14 +03:00
agent_coder 26d9a70a1c fix(share): не утекать errorText тулов и провайдера анониму в публичном шэре (closes #394)
SECURITY. В публичном share-чате сырой текст ошибки тула или провайдера утекал
анонимному читателю. Три слоя, все обязательны:

(1) Рендер-гейт: prop showErrors в ToolCallCard (протянут через MessageList/
MessageItem), share-виджет передаёт false — сырой errorText не рисуется. Но
рендер-гейт маскирует только DOM, не байты.

(2) Санитизация на уровне share-тулсета (авторитетно): forShare оборачивает
execute каждого тула catch'ем. Своя ShareToolError (безопасные строки: «page not
available in this share») пробрасывается, ЛЮБАЯ другая ошибка → generic «tool
could not complete», полный текст только в серверный лог. Одно место закрывает
байты (атомарный tool-output-error фрейм v6), рендер и контекст модели; self-
correction сохранена.

(3) Анонимный onError пайпа: ShareToolError → её безопасное сообщение; иначе
describeProviderError (statusCode + тело: внутренний baseUrl/модель) только в лог,
читателю — фиксированная классифицированная строка (rate-limited/unavailable/
provider error).

Тест: интеграционный с РЕАЛЬНЫМ падением тула и провайдера — assert по СЫРЫМ SSE-
БАЙТАМ (не по DOM): секрет/baseUrl/стек отсутствуют, видна безопасная строка,
полный текст провайдера ушёл в серверный лог.
2026-07-11 07:19:14 +03:00
agent_coder e1ebd79f30 fix(ai-chat): провал beginRun фейлит ход честным 503 (A_RUN_BEGIN_FAILED) (#486)
Раньше провал beginRun (кроме unique-violation), напр. blip пула БД, логировался
и ход ПРОДОЛЖАЛСЯ без run-строки. В autonomous такой ран никто не абортит: /stop
его не видит, дисконнект не абортит, one-run-гейт пропускает ВТОРОЙ ран — невидимый
неостанавливаемый ран до рестарта.

Теперь провал beginRun (кроме RunAlreadyActiveError → прежний 409) бросает
ServiceUnavailableException с кодом A_RUN_BEGIN_FAILED ДО первого байта и до
вставки user-строки (post-hijack catch контроллера отдаёт честный 503 на raw-
сокет). Без ветвления по режимам — #487 наследует ту же политику. В тело кладём
statusCode: 503 (object-arg исключение его не добавляет), чтобы клиент видел
статус.

Клиентский классификатор: ветка A_RUN_BEGIN_FAILED добавлена СТРОГО ДО generic-
503-матча — иначе показал бы «provider is not configured» вместо «временно,
повторите».

Тесты: unit fail-fast (stream() бросает 503 A_RUN_BEGIN_FAILED, ни байта в сокет,
user-строка не вставлена; RunAlreadyActiveError по-прежнему 409); unit клиентского
классификатора из ПОЛНОГО реального тела ответа с гвардом порядка.
2026-07-11 07:19:14 +03:00
agent_coder f6fc914c95 test(client): doc-changed-guard тесты вставки — сделать нехолостыми (#347, ревью F3)
Ревьюер мутационно доказал: 3 теста doc-changed-guard были ВХОЛОСТУЮ — зелёные даже
при обоих гардах `if(false)`. Причина: вставка в ПУСТОЙ курсор (from==to==1) +
mutateDoc только РАСТИТ док → протухший нулевой диапазон всегда валидная точка
вставки: replaceRange(1,1,…) не затирает, растущий док не выводит `to` за границы,
RangeError не бросается. Гард-код корректен — вхолостую были ТЕСТЫ.

Переписаны по рецепту ревьюера:
- success: вставка поверх НЕПУСТОГО выделения ("AAAABBBB", selection {1,5}); mid-flight
  вставить "MARKER" в pos 1 + курсор в конец. Рабочий гард → замена в живой (конечной)
  selection, MARKER цел; сломанный → stale {1,5} стирает голову MARKER.
- fail-open: захваченный `to` выводится ЗА ГРАНИЦЫ — после захвата {1,9} и провала
  конверсии док СЖИМАЕТСЯ до пустого параграфа. Рабочий гард → raw-текст в живую
  selection; сломанный → insertText(md,1,9) на size-2 доке → RangeError, ничего не
  ложится.
- two-pastes оставлен (пинит «ни один payload не потерян», не гард).

Мутационно проверено: оба гарда→if(false) → тесты 1 и 2 КРАСНЕЮТ (stale-range
clobber; stale-`to` RangeError), 3-й и не-гард-тесты зелёные; реальный гард
восстановлен → 6/6. Тесты теперь отличают рабочий гард от сломанного.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 03:34:43 +03:00
agent_coder 1d89cc2058 fix(client): ревью #347 — Reasoning-panel отступы, диагностика вставки, тест guard'а, комменты, доки (#347, ревью)
Правки по 5 находкам ревью #498.

F1 (регрессия отступов списков в Reasoning-панели): добавлен `.reasoningText li p
{margin:0}` (зеркало существующего `.markdown li p`). Reasoning рендерит через тот
же renderChatMarkdown (теперь всегда <li><p>…</p></li>), но под .reasoningText, где
`.reasoningText p{margin:0 0 4px}` давал 4px на пункт. Обе поверхности покрыты.

F2 (глухой catch): `.catch` вставки был `()=>{}` → теперь `(err)=>console.error(
"markdown paste conversion failed, inserting raw text", err)` — тихая деградация в
raw-текст больше не невидима (покрывает и конвертер, и тело success-.then, напр.
PMNode.fromJSON при дрейфе схемы).

F3 (нет теста doc-changed guard): +3 теста в markdown-clipboard.paste.test.ts:
success-ветка при mid-flight изменении дока → вставка в живую selection (маркер
цел, без клоббера/throw); fail-open ветка при mid-flight + провале конверсии →
raw-текст в живую selection без RangeError; две вставки в полёте → инвариант «ни
один payload не потерян».

F4 (устаревшие комменты): исправлены ссылки на удалённый md-слой в markdown-
clipboard.ts, footnote-sync/util(+test), docmost-schema, foreign-markdown,
footnote-canonicalize → на @docmost/prosemirror-markdown / локальные символы.

F5 (внешние доки): AGENTS.md (apps/client как потребитель через browser-entry,
jsdom только в Node, удалён marked/turndown-слой); prosemirror-markdown/README
(секция Node vs browser entry, markdownToProseMirrorSync); CHANGELOG.

Тесты: client paste+canonicalize+ai-chat 61; pmd 744; editor-ext 196; клиентская
сборка успешна, grep бандла на JSDOM/parse5/happy-dom/turndown — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 03:02:08 +03:00
agent_coder 5d8083f8ff refactor(client): вставка markdown через канонический @docmost/prosemirror-markdown + удаление md-слоя editor-ext (#347)
Третий шаг #345: клиентская вставка markdown переезжает с marked-слоя editor-ext
(не знал канона — ^[…], <!--img {…}-->, <!--subpages--> при вставке не
распознавались) на канонический пакет. По завершении слой удалён целиком. closes #347

- Browser-entry пакета (инъекция DOM-парсера): jsdom только в Node-пути.
  dom-parser.ts — 2 слота инъекции (HtmlDocumentParser для HTML→Document,
  GenerateJsonFn для @tiptap/html), без импорта DOM. dom-parser.node.ts
  регистрирует jsdom + @tiptap/html/server; dom-parser.browser.ts — нативный
  DOMParser + @tiptap/html (browser), экспонирован через exports-условие
  "browser" + сабпас "./browser". markdown-to-prosemirror.ts: убраны статический
  импорт jsdom и module-level global.window-шим. Клиент ВСЕГДА импортирует явный
  сабпас /browser — не полагается на порядок условий. Node-потребители (mcp/
  server) идут по "." → default → index.js → jsdom, не затронуты.
- markdown-clipboard.ts: конвертация через browser-entry (markdownToProseMirror
  → PM-JSON → HTML через живую схему редактора DOMSerializer → НЕИЗМЕНЁННЫЙ
  downstream-шов normalizeTableColumnWidths→parseSlice→canonicalizePastedFootnotes
  →dispatch). Эвристики/fragment-insertion не тронуты. Конвертер async → handle
  Paste захватывает диапазон, забирает событие, диспатчит на резолве; и success,
  и fail-open ветки защищены guard'ом doc!==startDoc (не диспатчить по устаревшему
  диапазону). clipboardTextSerializer (copy PM→md) — через convertProseMirror
  ToMarkdown.
- Удалён packages/editor-ext/src/lib/markdown/ целиком (+ marked из package.json).
  Мигрированы ВСЕ потребители markdownToHtml/htmlToMarkdown: ai-chat/utils/
  markdown.ts (→ новый markdownToProseMirrorSync + DOMSerializer), use-generate-
  page-title.ts / page-header-menu.tsx (→ convertProseMirrorToMarkdown(getJSON)),
  серверный spec. Grep: осиротевших импортов нет, editor-ext = только схема/
  расширения. Turndown ушёл из бандла (был в старом htmlToMarkdown).
- AI-чат теперь рендерит markdown через схему редактора (li в <p>); добавлен
  .markdown li p{margin:0} (CSS-модуль, скоуп только чата) — визуально плотно.

Проверка: pmd tsc + vitest 744; client build УСПЕШЕН, grep бандла на
JSDOM/parse5/happy-dom/turndown — 0 (утечки нет); клиентский suite + paste-тесты
зелёные (34); editor-ext 196; node-потребители (mcp/server/git-sync) зелёные.
Юнит-тесты: dual-path parity (jsdom==DOMParser), канон-формы == серверный импорт,
негативы ($5/==/[^1] не корёжатся), async-paste (claim→convert→dispatch, fail-open).

Ручная paste-QA полного клиентского round-trip (footnote/callout/math/image-comment;
вставка из VSCode/Obsidian/GitHub в список/таблицу/callout) и Docker-сборка клиента
(#333-класс) — за пределами автостенда, оставлено на ручную проверку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:11:19 +03:00
agent_coder 3cba551800 fix(ai-chat): «send now» во время detached-run — авторитетный серверный stop + ограниченный ретрай 409 (#396)
В автономном режиме «Interrupt and send now» во время живого detached-run делал
только локальный stop() (abort SSE), который сервер игнорирует (run живёт по
дизайну #184/#234), поэтому onFinish→flush новый POST упирался в гейт «один
активный run на чат» → 409 A_RUN_ALREADY_ACTIVE, новый turn не стартовал.
handleStop делает правильно (доп. onServerStop), sendNow — нет. Вариант A
(клиентский, горячий путь сервера не тронут):

- sendNow в автономном режиме дополнительно зовёт onServerStop(chatId) (или
  откладывает через stopPendingRef, если chatId ещё не усыновлён — как
  handleStop) и взводит one-shot supersedeRetryRef ДО stop();
- транспорт-fetch на supersede-отправке (и только на ней) ретраит РОВНО 409 с
  body.code===A_RUN_ALREADY_ACTIVE до 4 попыток с бэкоффом 150/300/600ms;
  onServerStop гарантирует осадку run → ретрай сходится. Обычная отправка (не
  взведён флаг) падает на 409 мгновенно. isRunAlreadyActive читает
  response.clone() → тело оригинала возвращается потребителю нетронутым.
  409 всегда до записи user-строки (pre-check/beginRun раньше insert) → повтор
  POST безопасен, дублей нет.

Внутренний цикл: 2 прохода. Ревью нашло CRITICAL: supersedeRetryRef застревал
взведённым, когда sendNow взвёл, но POST не ушёл (promoted head удалён →
flushNext() false; либо wasResumed-return) — следующая обычная отправка молча
ретраила настоящий 409. Починка: разоружать флаг симметрично остальным one-shot
(в ветке !flushNext() и в isStreaming-defuse-эффекте); транспорт read-and-clear
на входе каждой отправки. Мутационно: убрать disarm → strand-тест краснеет
(4 вызова вместо 1). Легаси-режим не тронут (регресс-гард).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:50:04 +03:00
vvzvlad 3903e2b823 Merge pull request 'perf(client): срезать фоновые ре-рендеры и дубли (#344)' (#360) from perf/344-background-rerenders into develop
Reviewed-on: #360
2026-07-10 07:03:35 +03:00
vvzvlad 629bcc906a Merge pull request 'perf(editor): срезать работу на каждый keystroke — латентность печати (#343)' (#357) from perf/343-typing-latency into develop
Reviewed-on: #357
2026-07-10 07:03:24 +03:00
vvzvlad 8d254aae23 Merge pull request 'perf(client): route + component code-splitting — eager 3.5МБ→1.12МБ (#342)' (#354) from perf/342-code-splitting into develop
Reviewed-on: #354
2026-07-10 07:03:14 +03:00
agent_coder 6bfb1e645a fix(client): sidebar-кеш не должен стирать icon/title на field-only событии (ревью #360)
invalidateOnUpdatePage для sidebar-pages кеша спредил сыро {...sidebarPage, title,
icon} — при title-only событии icon приходит undefined и затирался (и наоборот).
Embed-tree путь 20 строками выше уже гардит undefined; sidebar-ветка пропустила
тот же гард. Применён тот же паттерн: ...(title!==undefined?{title}:{}) +
...(icon!==undefined?{icon}:{}). +2 теста (sidebar title-only/icon-only:
непереданное поле не затирается); мутационно (вернуть сырой спред -> тесты краснеют).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 05:08:43 +03:00
agent_coder 6e59793643 fix(#344 review F1-F4): test-mock coverage + getSpaces freshness + comment/test fixes
- F1 [blocking]: share-modal.test.tsx + comment-content-view.test.tsx mocked
  page-query without usePageMetaQuery → 3 tests threw (ShareModal uses it
  directly, comment-content-view via MentionContent). Added usePageMetaQuery to
  both mocks (the space-tree mocks were already fixed; these two were missed).
- F2: restored refetchOnMount:true on useGetSpacesQuery — ["spaces"] is
  invalidated only by same-tab mutations (no socket path), so a cross-actor
  change (an admin adding/removing THIS user from a space) left the list stale
  until a hard reload. The other refetchOnMount removals (favorites/watched —
  per-user, same-tab-only gap) stay removed.
- F3: corrected the trash-list + recent-changes KEEP comments — both keys ARE
  invalidated (trash-list by 3 mutations, recent-changes by page CRUD), but
  invalidateQueries only marks an UNMOUNTED query stale without refetching, so the
  mount refetch closes the gap. The old "never invalidated" wording was wrong and
  risked a maintainer deleting a live invalidation as dead code.
- F4: tests for the two load-bearing pure paths — invalidate-on-update-page (the
  undefined-guard: a title-only event keeps the icon; sibling/unrelated subtrees
  untouched) and breadcrumb-path-equal (equal chain → true; any id/slugId/name/
  icon change or length diff → false; both-null → true). Exported
  breadcrumbPathEqual for the test.

Gate: client tsc 0; the 4 affected/new test files 33 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:37:36 +03:00
agent_coder 1bcc96685e perf(client): cut background re-renders + duplicate work (#344)
Outside the editor the UI did background work on every tree event, socket
reconnect, and navigation. Tree infra (virtualization/memo/O(N) utils) was
already good — the cost was in the subscriptions and duplicates around it.
Client-only; behavior 1:1.

- Setter-only atom subscriptions → useSetAtom: space-tree-row, use-tree-mutation,
  use-tree-socket no longer subscribe every visible row to the WHOLE treeDataAtom
  value (a tree event re-rendered all ~20-30 rows, bypassing the DocTreeRow memo).
  space-tree-node-menu / mention-list read the tree imperatively (store.get) in
  their handlers only. breadcrumb.tsx uses a selectAtom slice (ancestor chain +
  field equality) instead of the whole-tree subscription.
- Socket handler cleanup (BUG): use-tree-socket + use-query-subscription now
  socket.off() their named handlers on cleanup (were accumulating listeners on
  every reconnect → duplicated invalidations/tree-walks). Mirrors
  use-notification-socket.
- Field-update tree path: invalidateOnUpdatePage does a pointwise patch of the
  cached embed subtrees instead of a blanket invalidatePageTree() (refetch storm);
  structural events keep the blanket invalidate.
- usePageMetaQuery: a content-less select slice for the 13 peripheral subscribers
  that read only title/permissions/id, so they stop re-rendering every ~3s while
  typing / on every collab page.updated (page.tsx keeps the full query for content).
- page.tsx: skeleton + placeholderData keepPreviousData (no blank flash on nav).
- Removed refetchOnMount:true where socket/mutation invalidation already keeps the
  cache fresh (favorite/space/space-watcher/workspace). KEPT it on the 3 queries
  with NO other freshness path (trash-list, created-by, recent-changes) — the
  global default is refetchOnMount:false, so those overrides are load-bearing.
- Small: resize mousemove/up attached only while dragging; per-row emoji-picker
  keydown gated on `opened`; AiChatWindow queries enabled only when the window is
  open.

Gate: client tsc 0, client vitest page+websocket 200 passed (+editor suites),
build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:37:35 +03:00
vvzvlad 86830b860d Merge pull request 'feat(ai-chat): авто-реконнект к detached-рану после живого обрыва SSE' (#432) from feat/430-live-reconnect into develop
Reviewed-on: #432
2026-07-10 04:27:16 +03:00
agent_coder 22f687c39e feat(ai-chat): авто-реконнект к detached-рану после живого обрыва SSE
Автономный ран продолжается на сервере при обрыве SSE (Safari роняет длинный
стрим), но клиент показывал баннер 'Lost connection' и мёртвую вкладку до ручной
перезагрузки: resumeStream() звался только на mount. Добавлен недостающий триггер.

- chat-thread.tsx: в onFinish на живом isDisconnect (гард !wasResumed &&
  autonomousRunsEnabled && mounted && assistant) -> beginReconnect с экспон.
  backoff (1/2/4/8/16с, лимит 5). Стоп: status->streaming / 2xx re-attach /
  терминальный хвост reconcile / stop / unmount. Исчерпание -> Retry.
- Дедуп (главный риск): зеркалит mount strip/anchor — пиннит текущий streaming-ряд
  как anchor (id ассистент-строки), стрипает его из стора ДО replay, сервер
  ?expect=live&anchor=<id> пересобирает без дублей; на отказе/204 строка
  восстанавливается через onNoActiveStream (контент не теряется).
- 204/overflow -> degraded poll через существующий onNoActiveStream.
- RUN_STREAM_MAX_BUFFER_BYTES 4->32МБ (марафонские раны 11-25мин переполняли 4МБ);
  204->poll остаётся backstop. Degraded-poll: фиксированный 10-мин-от-старта кап
  заменён на inactivity-кап (продлевается пока приходят новые ряды).
- UI: баннер 'reconnecting… (N/5)' + ручной Retry на исчерпании.

closes #430

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 02:51:58 +03:00
agent_coder 9120ad3b2d feat(client): сноски — рендер без сдвига (номер инлайн через ::before) и шрифтом sm
Убран отдельный столбец-маркер .definitionMarker (order:-1, min-width:1.5em),
дававший висячий отступ. Номер сноски теперь рисуется инлайн в начале первого
параграфа через .definitionContent > :first-child::before из CSS-переменной
--footnote-number (в модель документа не попадает, экспорт не затрагивает).
Кегль сносок уменьшен до var(--mantine-font-size-sm). Инвариант #146 (contentDOM
первый в DOM) и логика мульти-бэклинков #168 сохранены.

closes #420

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:12:35 +03:00
agent_coder 7f88b0b441 test(ai-chat): pin toolInputSummary field priority + clamp boundary (#392)
Review round: add the two missing test-locks the reviewer asked for —
(1) priority order of PRIMARY_INPUT_FIELDS is now pinned (`{query,title}`
-> "Q", so a reordering breaks the test); (2) the clamp boundary is pinned
exactly (140 chars -> unchanged, no ellipsis; 141 -> 140 + "…"), catching
an off-by-one / `>` vs `>=` regression. Test-only, no production change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:40:03 +03:00
agent_coder cb445f0966 feat(ai-chat): show tool-call arguments in the action-log card (#392)
For tools without a friendly label (esp. external MCP tools like
Search_web_search) the card showed a generic "Ran tool <name>" with no
sense of what was searched. Add a compact one-line, dimmed summary of the
call's arguments under the label, pulled from part.input.

New pure toolInputSummary(part): picks the first present "primary" field
(query/q/searchQuery/url/urls/title/name/text/prompt), collapses
whitespace, clamps to ~140 chars; arrays render "first (+N)". It returns
undefined during input-streaming (the input grows while state is fixed and
messageSignature doesn't track input, so a live summary would freeze) —
the state flip to input-available re-renders the row with the final value,
so message-signature.ts is left untouched. The value renders ONLY through
Mantine <Text> (React-escaped) — no markdown/HTML, no XSS.

A showInput prop (default true) is threaded MessageList -> MessageItem
(+memo) -> ToolCallCard; the public share widget passes showInput={false}
so an anonymous reader never sees the agent's raw query text (mirrors
showCitations). No JSON fallback when no primary field is present.

closes #392

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:28:24 +03:00
agent_coder 15859e3b9f feat(ai-chat): getCurrentPage returns the user's editor selection (#388)
Snapshot the editor selection at send time (same live-ref pattern as
openPageRef in prepareSendMessagesRequest), carry it nested inside
openPage so it dies with the page on a fail-closed resolve, and surface
it to the model only through the existing core getCurrentPage tool.

The selection TEXT is returned exclusively in the tool result (untrusted
collaborative-page content, treated as data by SAFETY_FRAMEWORK); the
system prompt gets only a fixed one-line flag, never the text/before/
after. sanitizeSelection caps text (4000), before/after (200), blockIds
(<=64 chars, <=20). Selection is a hint, not ground truth — the tool
description tells the agent to localize the fragment before editing.

closes #388

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 18:50:55 +03:00
agent_coder 18eee98b7f fix(ai-chat): #381 PR2 review round 1 — F7 restart-survival + unmount abort + anchor-orphan
Do 1 [F7 regression]: транзиентный сбой attach больше не роняет строку и не
теряет ран. В transport fetch-wrapper `204 || !response.ok` оба зовут
onNoActiveStream (восстановить stripped-строку + invalidate + арм poll), и catch
зовёт его перед rethrow — раньше !ok/throw только сбрасывали флаг, и на 5xx/502/
network-blip in-progress ассистент-турн исчезал, durable-ран не отслеживался.
onNoActiveStream — суперсет (его часть-г всё ещё чистит флаг), идемпотентен.
Расширяет литеральный block-3 спеки (там был только сброс флага) — по ревью и
в согласии с интенцией окна «poll must survive a server restart».

Do 2 [stability]: attach-GET абортится при unmount + mount-гейтинг сайд-эффектов.
mountedRef: mount-эффект ре-армит true и в cleanup ставит false + abort
attachAbortRef; onNoActiveStream рано выходит на !mounted, onFinish-recovery
гейтится `wasResumed && mountedRef.current`. Снимает до-10-мин спурьёзный поллинг
+ чужую invalidateQueries + утёкший fetch на новооткрытом чате (и StrictMode
double-resume).

Do 3 [coherence]: anchor-mismatch не оставляет вечную dots-строку. Reconcile
после мержа хвоста мержит fresh-history версию stripped-строки, если её id !=
id хвоста — settl'ит осиротевшую streaming-A над раном B.

Тесты: F7 500 → restore+арм; F7 network-throw → restore+арм; unmount при pending
attach → abort + поздние колбэки не летят. vitest src/features/ai-chat 304
зелёных, grep-guard пуст.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:26:27 +03:00
agent_coder 067fc46170 feat(ai-chat): единый resumable SSE-транспорт — клиент + удаление поллинга/латчей (#381 PR 2)
PR 2 из 2 (#381, фаза 1.5 #184). Все вкладки теперь на ОДНОМ транспорте:
любая подключается к рану через GET-attach (реплей кадров + живой хвост,
реестр из PR 1). Наблюдатель — обычный стример; Stop, точки, инвалидации
работают штатно. Двухпутёвый поллинг снапшотов и вся latch-механика F4/F5/F7
удалены.

- utils/resume-helpers.ts (новый): isStreamingTail / isSettledAssistantTail /
  seedRows / mergeById (дословный перенос mergeObservedMessage из run-polling).
- components/chat-thread.tsx: resume-машинерия (гейтинг по не-settled хвосту,
  strip streaming-хвоста + attach ?expect=live&anchor=<row id>, транспорт
  prepareReconnectToStreamRequest+fetch, 204-обработчик из 4 частей,
  reconcile+degraded-merge, recovery с АСИММЕТРИЕЙ arm-vs-restore — при
  isDisconnect с видимым контентом только arm, без restore-клоббера живого
  стрима (инв. 9), строгий порядок onFinish с ранним return до обеих веток
  отправки (инв. 7), «Send now» скрыт на resumed-ходе, Stop абортит attach).
- components/ai-chat-window.tsx: degraded-poll фолбэк вместо латчей — тупой
  таймер (2500ms, 10-мин кап, без проверок ошибок/хвоста; переживает рестарт
  сервера), гасится тредом через onResumeFallback(false).
- Удалено: run-polling.ts(+test), useAiChatRunQuery/AI_CHAT_RUN_RQ_KEY,
  getAiChatRun (stopRun оставлен), IAiChatRun/IAiChatRunResponse, латчи
  stoppingRun/localStreaming/observedRow/onStreamingChange, F7-эффект,
  observer-merge. Серверный POST /ai-chat/run не тронут.

Проверка: tsc (мои файлы чисты), vitest src/features/ai-chat 34 файла/301 тест
зелёные, grep-guard по удалённым символам пуст. Отдельное внутреннее ревью на
инварианты 7/8/9 + 204-null-safety — чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:26:27 +03:00
agent_coder 51ded06fde fix(#342 review round-2 F5-F6): drop the posthog re-render remount + test chunk detector
- F5 [stability/regression]: the round-1 F2 fix re-rendered the root with
  <PostHogProvider><App/></PostHogProvider> after the analytics chunk loaded. In
  the ChunkLoadErrorBoundary child slot the element TYPE changes App ->
  PostHogProvider, so React does NOT reconcile in place — it REMOUNTS the whole
  App: every mount effect runs twice (websocket connect/disconnect, origin
  tracking, subscriptions) and local state / focus / scroll / in-progress input is
  lost on cloud cold-load (e.g. typing in /login before analytics loads). And it
  was USELESS: the app has ZERO consumers of the PostHog React context (no
  usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given an
  initialized client is a no-op — all capture goes through the posthog singleton.
  Fix: initAnalytics now inits the posthog SINGLETON only (no posthog-js/react
  import, no second render); renderApp() renders <App/> once. First paint stays
  instant, cloud analytics behavior unchanged, no remount.
- F6 [test]: exported isChunkLoadError + chunk-load-error-boundary.test.ts —
  pins the detector (ChunkLoadError name + the 3 dynamic-import failure messages,
  case-insensitive → true; null/undefined/ordinary errors → false) so a
  false-negative that re-blanks the app on a real chunk-404 is caught.

Gate: client tsc 0, chunk-load + sanitize tests 14 passed. Entry chunk unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:18:29 +03:00
agent_coder 456a91d289 fix(#342 review F1-F4): chunk-load error boundary + non-blocking posthog + tests
- F1 [HIGH]: added a root ChunkLoadErrorBoundary (react-error-boundary) wrapping
  the routed app in main.tsx, ABOVE all the route-level/Aside/AiChatWindow
  Suspense boundaries. A stale-deploy chunk 404 (React.lazy reject) is caught and
  auto-reloads once (sessionStorage-guarded against a reload loop), else shows a
  manual "new version available" reload UI — instead of unmounting the whole tree
  to a white screen. Existing per-feature ErrorBoundaries untouched.
- F2 [MED-HIGH]: posthog no longer blocks/blanks the cloud first paint. main.tsx
  now renders <App/> immediately for everyone, then `void initAnalytics()` — which
  keeps the exact cloud gate, dynamically imports posthog, and RE-RENDERS the same
  React root wrapped in PostHogProvider (React reconciles onto the painted DOM, so
  cloud ends up wrapped exactly as before). The import+init is try/catch'd: a
  failed analytics chunk (network / stale-404 / ad-blocker on a "posthog" chunk)
  degrades to no-analytics instead of a permanently blank page.
- F3: sanitize-url.test.ts mirroring editor-ext's security contract (javascript:/
  data:/vbscript:/obfuscated → ""; https/relative/mailto preserved).
- F4: the idle-warm `void import(...)` prefetch in layout.tsx gets `.catch(()=>{})`
  so a failed best-effort prefetch can't surface as an unhandledrejection.

No new deps (lockfile unchanged). Gate: client tsc 0, sanitize test 3/3, client
build succeeds (entry chunk still 556K, posthog in separate dynamic chunks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:18:29 +03:00
agent_coder 515c08afed perf(client): route + component code-splitting — eager JS 3.5MB -> 1.12MB (#342)
Everything sat in the eager startup graph (App.tsx statically imported all 28
routes; the editor pulled TipTap + KaTeX + ~45 lowlight grammars + drawio;
posthog + AI SDK loaded for everyone) — a /login visitor downloaded+compiled the
whole editor. Client-only; functionality 1:1, only WHEN code loads changed.

Result (prod build): eager JS 3.5MB -> ~1.12MB, entry 1920KB -> 552KB; KaTeX
(250KB) and the TipTap engine (~586KB) are now lazy chunks, off the startup path.

- App.tsx: route-level React.lazy + Suspense (editor Page, all settings/*, share,
  space/home routes). Auth/redirect/cold-start routes stay eager. Suspense lives
  inside Layout/ShareLayout around the Outlet so the shell stays mounted.
- Lazy KaTeX node views (math-inline-lazy/math-block-lazy) + lazy drawio
  (drawio-view-lazy/drawio-menu-lazy), mirroring mermaid/excalidraw, each with a
  node-sized Suspense placeholder so a slow chunk can't crash the editor.
- posthog-js is now a conditional dynamic import under the unchanged
  isCloud() && isPostHogEnabled gate — self-hosted never downloads it.
- AiChatWindow is React.lazy, mounted on first open and kept mounted (a live AI
  stream isn't torn down); renders null while closed (identical behavior).
- Cut eager TipTap pulls from always-loaded shell modules: editor-atoms /
  global-bridge Editor -> import type; Aside lazily loaded (page routes only);
  config.ts sanitizeUrl and use-clipboard execCommandCopy moved to client-local
  src/lib/{sanitize-url,copy-to-clipboard}.ts (byte-identical to the editor-ext
  originals, dropping the barrel's top-level @tiptap import); WebSocketStatus
  import replaced with the "connected" literal the status atom already stores.
- vite.config.ts: a vendor-katex chunk group (TipTap/PM/Yjs intentionally NOT
  grouped — grouping dragged the engine eager; documented in the config).
- lowlight grammar registration is left inside the (now-lazy) editor chunk:
  listLanguages()/highlighting are synchronous, so deferring registration would
  change behavior for marginal in-chunk gain — the route split already removes it
  from startup, which was the complaint.

Gate: client build succeeds, tsc --noEmit clean, frozen install EXIT 0 (added
@braintree/sanitize-url as a direct client dep + regenerated the lock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:18:29 +03:00
agent_coder a6ff7623db perf(editor): cut per-keystroke work on the typing hot path (#343)
The editor lagged while typing (worse with doc size, and under collaboration the
same cost is paid for every REMOTE keystroke). ProseMirror itself was fine — the
overhead was the surrounding work done on every transaction. Behavior is 1:1;
only WHEN work runs changed.

- getJSON() off the keystroke path: `onUpdate` no longer serializes the whole doc
  synchronously — the serialization now runs inside a 3s debounce (new hook
  use-page-content-cache.ts), flushed on unmount so the last snapshot isn't lost.
- footnote numbering: merged 3 per-docChanged O(n) doc walks into one, and
  short-circuit the whole-doc renumber when the doc has no footnotes and the
  transaction didn't insert one (step-slice scan — covers typing/paste/collab).
- toolbar: replaced per-keystroke `editor.can().undo()/.redo()` dry-runs with
  cheap history-depth reads (Yjs undoManager stack length / pm-history depth).
- render side-effect bug: `remote.attach()` moved out of the render body into a
  useEffect.
- debounced the TOC all-headings rescan and memoized the slash-command suggestion
  build (was rebuilt twice per keystroke).
- node menus (image/video/audio/pdf/callout/subpages): the per-transaction
  selectors early-return a cheap isActive check instead of running getAttributes +
  multiple alignment probes while their node type is inactive (shouldShow still
  controls display — appears exactly when it did).
- code blocks: the global selectionUpdate listener is now added only for mermaid
  blocks (the only consumer of the selected state), eliminating N listeners +
  N setStates per caret move for normal code blocks.

Deferred (documented, collab hot-path risk): full conditional menu MOUNTING
(menu-less-frame risk on same-tx context switch) and code-block re-tokenization
debounce / language-persist (self-dispatching meta tx + node-attr writes interact
with collab/undo). The route split from #342 already keeps lowlight off startup.

Gate: editor-ext build + 252/252 tests, client editor tests pass, tsc --noEmit 0,
client build ok. New tests: footnote no-footnote-doc → 0 traversals + numbering
unchanged; page-content-cache onUpdate-no-sync-getJSON + flush-on-unmount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:10:04 +03:00
agent_coder dab2660999 fix(gitmost-bridge): нейтрализовать сплошные thematic breaks в транскрипте
Round-1 нейтрализация ловила только spaced-разделители (`- - -`),
но пропускала сплошные `---`/`***`/`___`: на git-sync round-trip такая
строка становится horizontalRule, а он не несёт текста — строка терялась
целиком (хуже list/quote-порчи). Символ `_` вообще отсутствовал в regexе.

GITMOST_MD_BLOCK_TRIGGER_RE дополнен альтернативой на целую строку-
thematic-break `([-*_])(?:\s*\1){2,}\s*$` (3+ одинаковых `-`/`*`/`_`,
solid или через пробел); прежние группы переведены в non-capturing,
чтобы `\1` ссылался на единственную capture-группу. Обе копии regexа
(bridge и pm-markdown тест) синхронны.

Тесты: bare `---`/`***`/`___` документированы как text-losing
horizontalRule; ZWSP-нейтрализованная форма round-trip'ится параграфом
с сохранённым текстом. Кейсы падают на старом regexе.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:02:29 +03:00
agent_coder 751d55e9db fix(gitmost-bridge): neutralize col-0 block triggers in transcript lines + lock text-not-HTML (#378 review round 1)
Round-1 review found two issues:
- [regressions] Inserting a transcript line VERBATIM as a paragraph is unsafe on the
  git-sync doc->markdown->doc round-trip: the paragraph serializer emits text with no
  block-escape, so a line starting at col 0 with `- `/`> `/`# `/`1. `/```` ``` ````/`|`/
  `> [!info]` silently re-parses into a list/quote/heading/code/table/callout (the last
  hits the #359 callout machinery). Safe under the host contract (every line prefixed
  `You:`/`Speaker N:`, which starts with a letter) but the helper didn't enforce it, and
  leading-whitespace / stray lines exist. Fix: trim each kept line (drops the indent leak),
  and if it STILL begins with a col-0 markdown block trigger, prepend an invisible
  zero-width space (U+200B) so the trigger isn't at col 0 — the round-trip keeps it a
  paragraph. (Backslash-escape was rejected: `marked` consumes the `\` on re-import, so it
  would render visibly then vanish. ZWSP is invisible and never markdown-escaped.) The
  serializer's missing block-escape is the pre-existing root cause; this is the boundary
  defense. Prefixed transcript lines never match the trigger regex → left byte-exact.
- [test-coverage] The "inserted as TEXT, not HTML/markdown" contract wasn't locked (all
  test strings were plain alphabet). Added a test that inserts `<b>bold</b>
  <script>alert(1)</script> and *stars* and [link](x)` (with Bold/Italic/Link marks
  registered so an insertContent(html) regression would parse them) and asserts one
  verbatim text node, no marks, and getHTML() has no live tags.

Verified: apps/client tsc --noEmit 0 errors; gitmost bridge tests 4 passed; a NEW
prosemirror-markdown round-trip test (real convertProseMirrorToMarkdown ->
markdownToProseMirror) proves bare triggers corrupt while the ZWSP-neutralized form stays
a single byte-preserved paragraph and normal You:/Speaker N: lines round-trip byte-exact —
3 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:01:25 +03:00
agent_coder 02308012a6 feat(gitmost-bridge): insert transcript into the recording page (#377)
The native gitmost.app (stage 2) now sends a `transcript` field to
window.gitmost.createPageWithRecording, but the web bridge ignored it — the page
was created with audio only. Insert it.

- gitmost-recording.ts: add `transcript?: string` to GitmostCreatePagePayload
  (plain text, \n-separated `You:` / `Speaker N:` lines, ready to insert). Add a
  gitmostInsertTranscriptIntoEditor(editor, transcript) helper: a "Transcript"
  heading + one paragraph per non-empty line, each line inserted VERBATIM as a
  text node (never HTML → no injection), appended at doc end (below the audio).
  No-op when transcript is undefined/empty/whitespace-only/non-string.
- gitmost-global-bridge.tsx (createPageWithRecording): after the audio insert
  succeeds, call the helper inside a try/catch — best-effort, so a transcript
  failure can never turn the already-successful recording into an error (logs +
  still returns ok). Absent transcript → audio-only page, exactly as today.

DoD: recording with speech → page has audio AND a labeled transcript block;
without transcript → unchanged. Closes the web-side dependency of gitmost-app
stage 2. Verified: apps/client tsc --noEmit 0 errors; 2 unit tests
(transcript present → heading+paragraphs; undefined/""/whitespace/number/object
/null → no-op, doc unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:01:25 +03:00
agent_coder b8cce4f814 fix(#371): skipped role is not 'allInstalled', test the reason->action branch (review round 1)
F1 [WARNING] bundlePhase returned 'allInstalled' when a bundle's only
non-installed role was skipped (0 installed for it), so the collapsed green 'All
installed · up to date' header contradicted the open 'Installed 0 · 1 skipped'
plaque. It now returns 'mixed' whenever a skipped role is present. Fixed the test
that encoded the wrong behavior.

F2 [WARNING] The reason->action branch (name-conflict -> transient overlay +
'Rename & install'; already-installed -> informational, no button) lived only in
the component, untested. Extracted the two decisions into pure, unit-tested
helpers nameConflictSlugs() and partialOffersRename() and wired them into the
modal; both reason values are now covered.

F3 [low] Removed the unused useRef import (client eslint no-unused-vars is off, so
it shipped silently).

F4 [low] Extracted bundleCounts() as the single tally pass; bundlePhase and the
panel both derive from it instead of rescanning the roles array ~5x per render
(the same model<->component consolidation this PR is about).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 06:09:58 +03:00