62482827c9b4e7d4965304597b2a701ca5dbfe8f
1050 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
62482827c9 |
fix(ai-chat): ре-ревью #518 — CI-фиделити delta, attach-дубль, чистки (#491)
Ребейз на обновлённый feat/490 (PR #510, Option-A): стек #491 перенесён с
|
||
|
|
2a58b63e15 |
fix(ai-chat): ре-ревью #491 — дубль на getRun-fail + epoch RUN_FACT + doc (#491)
Ре-ревью нашло регрессию класса #137/#161 на пути отказа getRun при tail-only
re-seed:
- НАХОДКА 1 (MEDIUM/HIGH): в onFinish-disconnect ветка .catch (отказ getRun) на
локальном дропе входила в reconnect-ladder БЕЗ ре-сида и БЕЗ фильтра «живой»
частичной строки. anchorRef оставался устаревшим (mount-инициализатор), живая
частичная строка со шагами N..M-1 — последней; через ~1с реконнект строил
?anchor=&n=N_mount, и при живом ране с покрытием от N_mount (flaky-сеть: SSE и
getRun упали, сеть поднялась за 1с) сервер отдавал кадры ≥N_mount → SDK дописывал
их к строке, где они УЖЕ есть → дублирование (клиентского дедупа реплея против
parts нет). Фикс: восстановлена структурная гарантия удалённого resumeStream-
фильтра — на отказе getRun (и на no-persisted-row, и на no-cid) живая частичная
строка удаляется из стора по id + anchorRef=null → реконнект реплеит со start в
ЧИСТЫЙ стор (полная пересборка) либо 204→poll. Нет пути, где attach tail-applies
на строку с уже присутствующими шагами. Тест: getRun-reject на локальном
дисконнекте → живая строка отфильтрована + URL без параметров (mutation-verify:
без фикса тест краснеет — фильтр не срабатывает).
- НАХОДКА 2 (LOW): RUN_FACT в enterReconnect теперь epoch-штампуется (epoch:
stampEpoch), как везде (postRun): getRun-rtt расширяет окно onFinish→dispatch,
конкурентный SEND_LOCAL во время rtt теперь дропает устаревший RUN_FACT по I1,
а не перетирает runFact.runId нового хода.
- НАХОДКА 3 (LOW, doc): run-fsm.spec.md обновлён — stripRef/strippedRowRef →
anchorRef {id, stepsPersisted}, tail-only + re-seed-from-persist.
FSM run-fsm.ts не тронут; инварианты #488 (epoch/honor-in-stopping/ownership-reset/
disconnect-first/render-gate) сохранены. Клиент ai-chat vitest 399 зелёный, tsc 0
ai-chat-ошибок; сервер delta(6, реально исполняется)/registry/step-marker/attach +
integration attach — зелёное.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
2dfc5c5144 |
feat(ai-chat): клиентская активация tail-only resume + delta-поллинг (#491)
Переводит клиент на серверный tail-only контракт resume (задел commit 3),
не трогая FSM run-fsm.ts — меняется только рантайм-обвязка в chat-thread.tsx.
A. Убран STRIP-механизм. Seed теперь содержит ВСЕ персистнутые строки без
изъятия хвоста (стриминговый хвост — это шаги 0..N-1, к которым SDK-
продолжение дописывает tail). stripRef/strippedRowRef заменены на anchorRef
{ id, stepsPersisted } — персистнутая assistant-строка, питающая
?anchor=<id>&n=<stepsPersisted>. Восстановления stripped-строки на
204/NONE/starved удалены (строку никто не изымал — нечего восстанавливать);
invalidateQueries + диспатчи FSM сохранены. Блок anchor-mismatch в reconcile
сверяется по id из свежей персист-истории, а не по «живой» строке.
B. Вход в attaching/reconnecting — ВСЕГДА через re-seed из персиста; «живой»
стор НИКОГДА не база для tail-apply. На локальном FINISH_DISCONNECT (и на
live-follow повторном дропе observer-а) сначала getRun(chatId) → замена
«живой» частичной строки персистнутой по id (mergeById) + установка anchor,
и лишь ПОСЛЕ этого диспатч RUN_FACT + FINISH_DISCONNECT (который планирует
реконнект). Так attach не может продублировать частичный шаг N. Фильтр
«живой» строки в resumeStream-эффекте убран (его заменяет re-seed). Инварианты
FSM (I1 epoch-штамп, I4 honor-in-stopping, DISCONNECT-FIRST, сброс ownership на
терминалах, render-gate) сохранены.
C. URL attach: ?anchor=<id>&n=<stepsPersisted> при наличии якоря, без expect.
D. Degraded-поллинг переведён с полного рефетча всех страниц на дельту:
useAiChatMessagesQuery больше не поллит (seed один раз), а окно при
degradedPoll раз в 2.5с зовёт getAiChatMessagesDelta(chatId, cursor) и
идемпотентно по id мёржит строки в тот же infinite-query кэш через новый
чистый хелпер mergeDeltaRowsIntoPages. Арминг/разарм (onResumeFallback) и
idle-cap не тронуты.
Хелперы: seedRows удалён; добавлены stepsPersistedOf и mergeDeltaRowsIntoPages
(+ юнит-тесты на идемпотентность). Тесты chat-thread обновлены под новый URL,
seed-без-стрипа, re-seed-из-персиста на дисконнекте (mutation-verify: падают
без re-seed и при n мимо персиста) и 204→poll-без-restore. Весь ai-chat vitest
зелёный (398), tsc без новых ошибок.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
b8185a9f0e |
refactor(ai-chat): registry — step-aligned retention + tail-only attach (#491)
Реестр ран-стримов больше не буферизует до 32МБ сырых SSE-кадров на активный
ран и не выливает весь буфер в сокет синхронно при attach — это давало OOM на
1ГБ-контейнере при нескольких марафонских ранах. Теперь кольцо ограничено
(env-настраиваемо, по умолчанию 4МБ) и держится в границах за счёт ротации по
шагам.
Серверная часть (суть коммита):
- Штамповка кадров по шагам в ingestFrame. Штамп кадра = число `finish-step`
кадров ДО него (с 0); сам finish-step несёт текущее значение, затем счётчик
инкрементится. Так штамп совпадает с `metadata.stepsPersisted`: клиент с N
персистнутыми шагами имеет 0..N-1 в сиде и просит хвост `stamp >= N`. Границу
ловим дешёвым startsWith по `data: {"type":"finish-step"` — форма кадра
проверена эмпирически против ai@6.0.207 (одна часть на кадр, type всегда
первый ключ; кавычки в text-delta экранированы, ложных срабатываний нет).
- Кольцо ротируется ТОЛЬКО на подтверждённом персисте шага N
(`confirmPersistedStep`), сбрасывая кадры `stamp < N` (эти шаги уже на диске и
придут в свежем сиде). `updateStreaming` теперь СИГНАЛИЗИРУЕТ исход (число
персистнутых шагов или null), и ротация вызывается лишь при не-null возврате —
провал персиста ничего не ротирует, кольцо покрывает БОЛЬШЕ (анти-инверсия:
наивная ротация в .then() после НЕзаписанного шага дырявила бы гарантию).
- Переполнение кольца сверх байтового капа вытесняет старейшие кадры; вытеснение
ещё-не-персистнутого кадра открывает GAP. Гэп НЕ липкий: floor покрытия
считается из кольца, поздний персист, проротировав дырявые шаги, его чистит.
- attach(chatId, anchor, n): маркер шага N приходит ТОЛЬКО от клиента (сервер не
читает строку — N из устаревшего сида дал бы тихую дыру в один шаг). Покрытие
ОК ⟺ coverageFloor <= n; иначе 204 → клиент рефетчит (больший N) и
переподключается. Хвост = синтетический `start`-кадр (ран-факт runId/chatId) +
кадры `stamp >= n`. Инвариант 6 (нет кросс-ран реплея) сохранён через anchor;
инвариант 4 (снапшот+регистрация в один синхронный тик) сохранён. N-срез
применяется во ВСЕХ ветках, включая finished-retained: finished + N=N_final →
пустой хвост + finish-кадр, клиент закрывает стрим.
- Контроллер пишет хвост чанками с учётом drain (writeTailRespectingDrain), а не
синхронным залпом (вторая половина OOM). Кап подписчика — производное 2× кап
кольца, обе величины env-резолвятся на инстансе.
Клиент: в тип строки добавлен `metadata.stepsPersisted` (источник N). PIN-SPEC
трип-вайр на ai@6.0.207: `readUIMessageStream({ message })` продолжает последнее
сообщение, `start`-кадр не сбрасывает parts, текст не пересекает finish-step —
на этом держится продолжение при attach; апгрейд ai теперь падает громко.
Тесты (observable-property против РЕАЛЬНОГО реестра/БД): детектор границы на
реальной форме кадра, N-срез (в т.ч. посреди шага), ротация только на
подтверждённом персисте, «персист провалился но кольцо влезло → attach успешен /
провал + переполнение → 204», «устаревший N → 204 → после рефетча успех», очистка
гэпа поздним персистом, finished-retained + N_final, memory-bound (5 параллельных
марафонов сверх 32МБ, каждое кольцо ≤ кап). Обновлены registry/controller specs и
DB-backed интеграционный attach-spec под новую сигнатуру/семантику.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
1e28151f7a |
perf(ai-chat): дельта-эндпоинт поллинга + run-факт в ответе (#491)
Degraded-poll рефетчил ВСЕ страницы infinite-query каждые 2.5 c с полными
parts. Вводим дельта-эндпоинт «строки, изменённые после курсора»:
- POST /ai-chat/messages/delta → { rows, cursor, run: {id,status}|null }.
Курсор — таймстамп часов БД (now()), клиент эхом возвращает его каждый
поллинг. Окно перекрытия now()−5s ловит строку, закоммиченную с updatedAt
чуть раньше момента снятия предыдущего курсора на другом autocommit-
соединении (одиночные UPDATE, длинных транзакций нет). Окно ГАРАНТИРУЕТ
повторы → контракт: клиентский merge идемпотентен по id (mergeById).
- Run-факт едет В дельте (отдельный /run-поллинг удвоил бы QPS — отвергнуто).
- Все дельта-релевантные записи (message update/finalizeOwner/reconcile/sweep,
run update/finalizeIfActive/markStopRequested/sweepRunning) стампуют
updatedAt через SQL now(), а не app-clock new Date(): единая монотонная ось
курсора, смешанные источники часов были независимым источником пропусков.
- Клиент: сервис-функция getAiChatMessagesDelta + фиксация контракта
идемпотентности mergeById тестом (свап degraded-поллинга на дельту — в
коммите 3, вместе с re-seed-путём attach).
Тесты (observable-property на живом Postgres, не моки):
- дельта-семантика + монотонность курсора;
- RACE «коммит позже с updatedAt раньше курсора» — ловится окном перекрытия
(наивный updatedAt > cursor пропустил бы);
- окно гарантирует повторы → идемпотентный merge;
- updatedAt стампуется часами БД, а не app-clock (фейк системных часов в 2099
— стамп остаётся на времени БД);
- контроллер: owner-gate, форма ответа, run-факт только {id,status}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
d4b21957dc |
feat(ai-chat): токен-бюджет реплея истории + реактивная ветка (#490)
Вся персистентная история реплеится провайдеру КАЖДЫЙ ход, поэтому длинный чат рано или поздно упирается в контекстное окно и получает провайдерский 400 на каждом ходу — навсегда (чат «кирпичится»). Бюджетер ограничивает РЕПЛЕЙ (никогда не мутирует персист — в БД остаётся полная запись), детерминированно и byte-stable (обрезанный префикс идентичен от хода к ходу → дружелюбно к prompt-cache). Единый оценщик chars/2.5 (кириллица; chars/4 занижает вдвое) вынесен в shared-пакет packages/token-estimate; клиентский count-stream-tokens.ts переведён на него ТЕМ ЖЕ коммитом (два расходящихся оценщика = «бейдж 60%, а бюджет уже режет»). history-budget.ts (чистый, покрыт тестами): - resolveReplayBudget(raw): min(100k, 0.7×window) при заданном окне; флэт 100k при незаданном (именно эти инсталляции ловят терминальный overflow — warn-лог); 0 = явный off-switch. Читается СЫРОЙ chatContextWindow, т.к. parsePositiveInt схлопывает 0 и unset в undefined (новое поле ResolvedAiConfig.chatContextWindowRaw). - trimHistoryForReplay: первичный сигнал — провайдерский факт metadata.contextTokens прошлого хода; chars-оценка — дельта/раскройка/фолбэк. Порядок: обрезка tool-outputs старых ходов (head+tail+маркер) → механическое схлопывание старейших ходов (конкатенация, НЕ LLM) → текущий + последние N ходов всегда полные. Пейринг tool-call/result сохраняется (схлопывание убирает ОБЕ части). - isContextOverflowError: классификация провайдерского 400 (статус + паттерны). Реактивная ветка: превентивная оценка не даёт инварианта (первый переполняющий ход не имеет usage). onError классифицирует context-overflow → пишет различимую причину и штампует metadata.replayOverflow; следующий ход бюджетер режет агрессивно (0.5×), что и раскирпичивает чат. Наблюдаемость: metadata.replayTrimmedToTokens. ПРИМЕЧАНИЕ по реактивной ветке (форк, требует решения ревьюера): истинный in-turn re-pipe (перезапуск streamText в тот же ответ) архитектурно несовместим с текущим пайпом — pipeUIMessageStreamToResponse пишет writeHead СИНХРОННО (подтверждено в ai@6.0.207), а suite ожидает await stream() c моком, не дёргающим колбэки, — так что отложенный пайп/ожидание сигнала повесит тесты. Поэтому реализована реактивная рекавери «классификация → штамп → агрессивный ре-трим на следующем ходу», что даёт тот же инвариант (чат не кирпичится) без рискованного рефактора стрима. Тесты (наблюдаемые свойства): объём записи через дельту pg_current_wal_lsn() на живой gitmost-test-pg вокруг 50-шагового прогона (несжимаемые payload'ы) — trace-колонка v1=140МБ → v2=0.04МБ (в 3206× меньше), полная строка 289МБ → 140МБ (−51%); dual-shape не нужен здесь; «окно не задано → бюджет применяется»; реактивная классификация на реальном 400-шейпе; parity клиент/сервер оценщика. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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/стек отсутствуют, видна безопасная строка, полный текст провайдера ушёл в серверный лог. |
||
|
|
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 клиентского классификатора из ПОЛНОГО реального тела ответа с гвардом порядка. |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
3903e2b823 |
Merge pull request 'perf(client): срезать фоновые ре-рендеры и дубли (#344)' (#360) from perf/344-background-rerenders into develop
Reviewed-on: #360 |
||
|
|
629bcc906a |
Merge pull request 'perf(editor): срезать работу на каждый keystroke — латентность печати (#343)' (#357) from perf/343-typing-latency into develop
Reviewed-on: #357 |
||
|
|
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 |
||
|
|
e4487d8628 |
Merge pull request 'perf(delivery): пре-сжатие статики + кэш-заголовки + сжатие API (#346)' (#352) from perf/346-compression-cache into develop
Reviewed-on: #352 |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
86830b860d |
Merge pull request 'feat(ai-chat): авто-реконнект к detached-рану после живого обрыва SSE' (#432) from feat/430-live-reconnect into develop
Reviewed-on: #432 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
babc42c2ff |
fix(#346 review F1-F4): no 206-compress + Vary + precompress VAD + cache test
- F1 [HIGH — data corruption]: @fastify/compress was compressing 206/Range attachment responses while Content-Range still described the RAW offsets, so a resuming client (curl -C -, download managers) appended encoded bytes as raw → corrupted file. sendFileResponse now sets the request header `x-no-compression` (the documented @fastify/compress opt-out — its onSend skips when the request carries it; the reviewer's `Content-Encoding: identity` does NOT work because compress explicitly excludes `identity` and overwrites it). This opts the whole download route (both 200 full-file and 206 range) out of on-the-fly compression — correct, since attachment bytes are final and mostly binary. - F2: static responses now emit `Vary: Accept-Encoding` (the preCompressed content-negotiated /assets/* were `immutable` without Vary → shared-cache could serve a brotli variant to an identity/gzip-only client). - F3: vite compression `include` extended to .wasm/.onnx so the VAD binaries (~26MB .wasm, ~2.3MB .onnx under public/vad) are precompressed at build (.br emitted) instead of runtime-brotli'd on every request. (include REPLACES the plugin default, so the default js/css/json/html set is re-listed.) - F4: extracted the cache classification into a pure `resolveStaticAssetHeaders` + static.module.spec.ts (3 tests: /assets/* immutable+Vary, index.html no-store, non-hashed not-immutable). Gate: server tsc 0 (deps present), static.module.spec 3/3, client build emits .wasm.br/.onnx.br, frozen install 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6ee814b7f3 |
perf(delivery): pre-compress static + cache headers + compress API responses (#346)
Cold load served ALL static + API responses uncompressed and without cache headers (~3.7MB over the wire). Delivery only — feature behavior unchanged; no DB/API-contract/MCP changes. - apps/client/vite.config.ts: vite-plugin-compression2 emits .br + .gz next to each built asset (excludes index.html, which the server rewrites at boot with window.CONFIG — a precompressed copy would go stale). Build emits 187 .br / 175 .gz under dist/assets. - static.module.ts: @fastify/static `preCompressed: true` serves the .br/.gz neighbour; `setHeaders` sets `immutable` ONLY for content-hashed /assets/*, `no-cache` for index.html, and leaves non-hashed files (locales, vad, icons, manifest) on default etag/last-modified revalidation. - main.ts: @fastify/compress (threshold 1024) compresses dynamic API JSON + the rewritten share-SEO HTML. SSE is safe on two counts: `text/event-stream` is not mime-db-compressible (allowlist skips it) AND the AI-chat stream hijacks the raw socket (pipeUIMessageStreamToResponse -> res.raw), bypassing the Fastify onSend lifecycle entirely. No double-compression with preCompressed static (compress skips already-Content-Encoding'd responses). - docker-compose.yml: comment recommending an optional HTTP/2 + brotli reverse proxy (not required). Deps: apps/client vite-plugin-compression2 2.5.3 (dev), apps/server @fastify/compress 9.0.0 (matches fastify 5.8.5). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
a325ddbabd |
feat(#371): roles catalog modal redesign — bundle cards + per-role import results
Integrates the designer-handoff Roles Catalog modal, wired to the real API; the
parent ai-agent-roles.tsx and the { opened, onClose, roles } contract are
unchanged.
- Server importFromCatalog now returns per-role lists (createdRoles /
skippedRoles with a reason) alongside the existing counters (compat-preserving),
so the UI can name the conflicting/installed roles.
- New pure view-model (catalog-bundle-model.ts): bundlePhase (empty | allNew |
allInstalled | updates | mixed, ignoring the transient 'skipped'),
installedLangForRole (same-slug-different-language hint), mapCatalogRoleToView —
all unit-tested without mounting.
- Bundle cards with a summary status in the collapsed header (eager useQueries
fan-out over all bundles, sharing the existing per-bundle cache keys), a single
primary action per bundle, checkboxes + select/deselect-all, an inline result
plaque that keeps the modal open, per-bundle and global 'Update all' request
series with progress, and the other-language hint.
- The partial-result plaque distinguishes the skip reason: only a name-conflict
offers 'Rename & install'; an already-installed race is informational (a rename
re-import would just skip again and self-heal into a false success).
- All strings i18n'd (en/ru); mock handoff code (SEED/mockImport/delay) removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
f665f6fdd2 |
Merge pull request 'feat(ai-chat): autonomous agent runs — phase 1: durable detached runs (#184)' (#234) from feat/184-autonomous-agent-runs into develop
Reviewed-on: #234 |