Port the NewDesign/TimeWorkedModal prototype onto the existing work-time
feature with zero backend changes. Each daily track now shows WHEN work
happened: a sticky 00/06/12/18/24 hour axis, shaded night hours (0–6, 21–24),
a per-block hover tooltip "start – end · duration", and a "now" boundary on
today's row. Work vs agent windows keep their existing colour semantics.
The prototype's invented props (DaySummary[], pre-made labels) are replaced by
a pure, unit-tested adapter (work-time-adapter.ts) over the real IPageWorkTime:
windows → time-of-day blocks (epoch kept for a DST-safe tooltip), ms → labels
via the shared formatters, in-place empty-run collapsing, and the today-only
now-line. The agent-only fail-safe (#395/#551) is preserved — the agent
estimate fills the main slot so it never renders empty.
Reuses usePageWorkTime, the endpoint, tz bucketing and format-work-time; deletes
the scratch prototype. New tooltip i18n key added to en-US/ru-RU.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reviewer changes-requested on #565.
F2 (real bug): a save on an empty page (or a missing page row) hung the client
for the full 20s ack window and then falsely reported the collab server as
unreachable + advised retry. Root cause: handleSaveVersion gated its
broadcastStateless on `if (result)`, and `result` stays undefined on the two
reachable early returns (isEmptyParagraphDoc and !page), so the server sent
NOTHING and the client waited out its timeout.
- SERVER (persistence.extension.ts): both early-return branches now record a
skip reason and the tail broadcasts a terminal `version.skipped` reply
(reason 'empty' | 'page-not-found'). Exactly one terminal reply per handled
save. Added VERSION_SAVED/VERSION_SKIPPED message consts.
- CLIENT (collaboration.ts): the predicate now matches both version.saved and
version.skipped; SaveVersionResult gains {saved, skipped, reason}. An empty
skip resolves to a clean {saved:false, skipped:true, reason:'empty'} (no
stall); page-not-found throws an immediate, truthful error; the genuine
no-reply timeout path is unchanged (that is the real "unreachable" case). A
terminal reply leaves the session cached (healthy connection); only a
transport failure destroys it. Kept the message-literal in-sync comments.
- tool-specs.ts / pages.ts: description + docstring reflect the new result shape.
F1 (coverage): added tests for the 4 previously-uncovered sendStatelessAndAwait
branches — unrelated-then-real (predicate filters noise), teardown-mid-wait
(rejects + removes the stateless listener, no leak), concurrent-in-flight
guard — plus the F2 empty-skip / page-not-found client outcomes. Server spec
gains empty-page and page-not-found terminal-reply tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Stage-A save_page_version tool's client method must be listed in the
DocmostClientMethod union so Pick<DocmostClient, DocmostClientMethod> exposes it
to the in-app tool adapter; the shared-tool-specs contract spec asserts parity.
Mirrors the existing restorePageVersion entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage A of #370: wire the MCP client transport for the already-shipped server
save-version handler (PR-1 #374). An agent can now pin an intentional named
version (kind='agent', derived server-side from the signed actor) of a page's
CURRENT live collaboration content.
- collab-session.ts: add CollabSession.sendStatelessAndAwait(payload, predicate,
timeoutMs) — sends a stateless message over the live provider and resolves on
the first matching reply, with a bounded timeout; lifecycle (inflightReject,
ready-guard, concurrent fail-fast, idle re-arm) mirrors mutate(). Extend
CollabProviderLike with sendStateless + the stateless on/off overloads.
- collaboration.ts: add savePageVersionRealtime() — under withPageLock, reuse the
cached agent-authenticated CollabSession (#400), send {type:'save-version'},
await {type:'version.saved', …}; no REST read (would race the stale page row).
- client/pages.ts: add savePageVersion(pageId) — resolvePageId +
getCollabTokenWithReauth + writeWithCollabAuthRetry (#486 self-heal).
- tool-specs.ts: add the deferred savePageVersion spec (+ DocmostClientLike Pick);
auto-registered on both hosts by the shared loops.
- server-instructions.ts: HISTORY family + routing-prose mention.
- ai-chat-tools.service.ts: contract type-assert for the new client method.
- agent-roles-catalog: tell the content-authoring roles (researcher,
call-summarizer, ru+en) to save a version when the document is done; bump
their catalog versions.
- test: unit coverage for savePageVersionRealtime (ack + bounded-timeout paths).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
en-US (source of truth) and ru-RU were missing ~25 strings introduced by the
api-key management UI, so Russian users saw the controls in English and en-US
was incomplete. Add every new t() key to both locales (24 new in en-US, 26 in
ru-RU) with natural Russian translations, placeholders ({{name}}/{{date}})
preserved; existing keys left untouched (insertions only, no reordering).
Also fix the createApiKey() lifecycle comment: the gcTime:0 + invalidation
lives in queries/api-key-query.ts (there is no use-api-key-query file) and the
reset()-after-read of the token lives in components/api-keys-manager.tsx
handleCreate (create-api-key-modal.tsx only resets the form).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix 1: an already-expired key rendered the forward-looking "Expiring soon"
badge/tooltip ("expires within 30 days"), which is false for a past expiry.
Add a pure isExpired() helper and, in ApiKeysManager, render a red "Expired"
badge ("This key has expired") for past expiries; keep the orange "Expiring
soon" badge only for keys expiring in the future within 30 days (the two
states are made mutually exclusive at the call site). Extend utils.test.ts
with isExpired coverage (past/future/null/exact-now boundary) and add a
component assertion that an expired key shows "Expired" and NOT "Expiring
soon".
Fix 2: the response-contract unwrap (res.data turning the server envelope
{data:{token,apiKey},success,status} into {token,apiKey}) was exercised by no
test — component tests fully mock the service. Add a service-layer unit test
that mocks the api-client (axios instance) and asserts createApiKey unwraps to
the inner {token,apiKey} payload and getApiKeys resolves to the row array.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 поверх серверных эндпоинтов #501: страница «Настройки → Аккаунт →
API-ключи» (lazy-роут, отдельный чанк) — список, создание, показ токена
один раз и отзыв.
- Список: явная дата истечения (не «через N дней»), подсветка ключей с
истечением < 30 дней, «использован» с семантикой «в течение последнего
часа» (last_used_at троттлится на 1 ч серверно).
- Создание: имя + срок (30д/90д/1 год[дефолт]/бессрочный → null). После
submit — модалка показа токена один раз с копированием и явной датой.
- Токен-материал живёт ТОЛЬКО в state открытой модалки: mutateAsync +
reset() чистит копию из query-cache, ничего не пишется в localStorage.
Закрытие модалки — токен исчезает навсегда.
- Отзыв: подтверждение → revoke → строка уходит из списка.
- Admin (CASL Manage на API = owner/admin) видит ключи всего воркспейса с
колонкой «автор»; обычный член — только свои.
Тесты (vitest): показ-один-раз + отсутствие токена в localStorage/кэше,
явная дата + подсветка < 30 дней, отзыв убирает строку, admin/member вид,
дефолт срока = 1 год и «бессрочный» → null. Стаб ResizeObserver добавлен в
общий vitest.setup для рендера Mantine ScrollArea/Table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the top-anchored Mantine notification containers from top:96px to
top:8px so success toasts (e.g. "Comment resolved successfully") appear
at the very top of the page, above the header/search, instead of
covering page content. Update the accompanying comment to reflect the
new intent (toast renders over the header chrome while visible).
Introduce redesigned UI elements for the new design system:
- **CommentsPanel** – displays agent‑driven edits with diff view, batch actions, and filtering.
- **PageHistoryModal** – full‑screen modal for browsing page revisions, includes a mini‑calendar and version rendering.
- **TimeWorkedModal** – visualizes work and agent sessions per day with grid or phase axes.
These components replace legacy panels, use Mantine v7, support dark/light themes, and provide richer interactions.
Решение владельца по #520 (эпик #497 итерация 5): при повторном overflow реактивная
рекавери ДОЛЖНА иметь право резать бюджет реплея ниже явно настроенного окна —
«чат не должен кирпичиться на overflow» это ИНВАРИАНТ реактивной ветки, а
настроенное окно — заявление о ёмкости модели, не обещание о размере реплея; когда
провайдер 400-ит, реальность бьёт конфиг. (Не конфликтует с #510 Опция A: та про
верхнюю границу НОРМАЛЬНОГО пути — уважать конфиг, пока он влезает.)
- Пол эскалации: max(scaled, min(REPLAY_MIN_FLOOR_TOKENS, floor(0.5×threshold))).
Было min(REPLAY_MIN_FLOOR_TOKENS, threshold) — жёсткий пол на настроенном бюджете.
Теперь БОЛЬШОЕ окно эскалирует НИЖЕ настроенного бюджета до фикс. пола 8k; МАЛОЕ
окно (0.5×threshold < 8k) падает до floor(0.5×threshold) — минимум старый 0.5× cut
(никогда не хуже, чем раньше), но и не поднимается выше самого бюджета.
REPLAY_MIN_FLOOR_TOKENS=8k без изменений (константа уже была). Сброс k на чистом
ходу сохранён (счётчик replayOverflowCount не пишется на чистом финализе).
- Наблюдаемость: warn-лог (настроенный бюджет не влезает, реплей ниже него, уровень
k) + метка турна metadata.replayBelowConfiguredBudget=true — только когда реплей
реально ниже настроенного бюджета (не на нормальном in-budget реплее).
- Тесты: перепиновка «не раздувает малый бюджет выше себя» (теперь МОЖЕТ резать ниже,
Опция B) в обоих spec; новый тест эскалации ниже бюджета (окно 8000→бюджет 5600,
k=1→2800, сходимость к полу, сброс на чистом ходу) + большое окно (140k→8k
ступенями); тест метки метадаты. Мутации: пол=бюджет краснит below-budget тесты,
снятие метки краснит observability-тест.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Остаточный кирпич (#490/#510): при незаданном chatContextWindow база = плоский
дефолт 100k, а реальное окно модели маленькое (<50k). Фиксированный одинарный
cut 0.5×100k=50k всё равно превышал реальное окно → провайдер снова 400,
строка переставлялась replayOverflow, но булев priorOverflowed уже был true →
второго ужатия не происходило. Чат навсегда застревал на 50k и не восстанавливался.
Фикс (без парсинга тел 400):
- Сигнал из булева переведён в счётчик `metadata.replayOverflowCount` = число
ПОДРЯД идущих overflow-ходов: инкремент (prior+1) на каждом overflow, сброс в 0
на любом чистом финализе (чистая строка не пишет поле → читается как 0).
BACK-COMPAT: старая строка с булевым `replayOverflow:true` читается как k=1.
- resolveEffectiveReplayThreshold(threshold, k) = max(floor(threshold·0.5**k),
min(REPLAY_MIN_FLOOR_TOKENS, threshold)). k=0 → база; k=1 → 0.5×; k=2 → 0.25×;
большой k → упирается в пол 8k (сходимость). null-база (trimming OFF) не трогается;
пол никогда не поднимает легитимно малый настроенный бюджет выше него самого.
- Пол REPLAY_MIN_FLOOR_TOKENS=8k: ниже него чат не несёт осмысленный недавний
контекст, и даже малое реальное окно его вмещает; keep-recent-turns сверху.
Тесты: таблица эскалации (k=0/1/2/большой/null), регрессия остаточного кирпича
(база 100k, окно ~40k → сходится ниже 40k, чего фиксированный 0.5× никогда не мог),
жизненный цикл счётчика на реальном pg (инкремент/сброс/back-compat через jsonb),
мутация **k→**1 краснит convergence-тест.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A literal inline-HTML break tag typed as prose text (`<br>`, `<br/>`,
`<br />`) was emitted verbatim into markdown, so on re-import marked
parsed it as an inline-HTML line break and silently turned the user's
text into a hardBreak node, dropping the text. HTML-entity-encode only
the angle brackets of a break-tag sequence in `case "text"` so it lands
as `<br>` and the importer decodes it back to literal `<br>`.
Scoped strictly to the `<br…>` pattern (not every `<`/`>`), so stray
angle brackets in prose (`a < b > c`) are untouched, and to the
text-content path only — a real hardBreak serializes from its own case
(` \n`, or `<br>` via inlineToHtml), so the serializer's own emitted
breaks are never escaped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: cap parsed terms at MAX_PARSED_TERMS (64) in parseSearchQuery so a huge
pasted query no longer nests the combined tsquery deep enough to blow Postgres'
stack depth limit (HTTP 500); +@MaxLength(10000) on SearchDTO.query as
defense-in-depth. F2/F3: restore titleOnly text_content leak-guard and
parentPageId subtree-scoping coverage in the lexical int-spec. F4: positive
ancestor-path ordering + non-empty snippet asserts. F5: remove dead
SearchLookupTier enum, unused buildTsQuery + pg-tsquery require (and its tests),
and the discarded parentPageId select in fetchDetails. F6: rewrite the stale
#443 DTO comment (parentPageId/titleOnly read by the engine; substring ignored).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rebase pulled develop's #494 REVERSE prose drift-guard, which flags every
camelCase token in ROUTING_PROSE that is not a registered MCP tool. #529's search
routing prose documents the search RESPONSE fields matchedTerms/matchedFields/
hasMore/truncatedAtCap — genuine non-tool terms — so add them to
PROSE_NON_TOOL_TERMS (develop's designed allowlist). Reconciles both invariants:
#529's search-prose enforcement and #494's dead-reference guard both stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Develop introduced two migrations at the exact 20260707T120000 stamp
(ai-chat-metadata, page-history-kind). Three files sharing one timestamp
is fragile for deploy ordering, so move #529's ru_en-config migration to a
strictly-later, unique 20260707T130000 so it orders cleanly last. It touches
only the ru_en text-search config (different tables), so running it after the
develop migrations is order-independent and safe.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
S1: fetchDetails now builds rank/highlight/matchedFields from the same
[...positive, ...required] set matchedTerms uses, so a required-only query
(`+кофейня`) no longer reports matchedFields:[] / rank:null.
W1: documented that CANDIDATE_CAP bounds pagination reachability (JS-side), not
query compute — a SQL LIMIT would corrupt the exact permission-filtered total.
S2: one-line note that literal-`%`/`_` search was intentionally dropped (total:0).
W3: restored the trgm-index EXPLAIN guard (search-lookup-explain.int-spec) that
asserts the coalesce-free substring predicates use idx_pages_*_trgm, not Seq Scan.
W4: added an integration ordering test proving title-exact > title-substring >
text-only tier dominance under RRF.
S3: added a test that an exact-title hit survives a tiny CANDIDATE_CAP window.
S5: server pretest now also builds @docmost/mcp so the ToolWriteClass import
resolves from clean CI (mirrors editor-ext/prosemirror-markdown).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BLOCKER B1: the fts config swap no longer blindly DROP+ADDs the generated
column. swapEmbeddingsFtsConfig now (1) reads the column's actual generation
expression from pg_catalog and TRUE-no-ops when it already references the target
config (real out-of-band escape hatch), and (2) gates the inline ACCESS
EXCLUSIVE rewrite behind SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE (default 'true';
only a literal 'false' opts out), warning that the operator owns the swap.
up() now creates ru_en only when missing (drop-recreate would fail on the fts
hard dependency on a re-run), so up() is genuinely idempotent. down() guards the
config DROP when fts still references ru_en (gated-off path), staying non-fatal.
W2: corrected the header — Kysely runs EACH migration in its own transaction
(not the whole set in ONE); the single-UPDATE atomicity conclusion is unchanged.
Rewrote the runbook: inline path is a full-table ACCESS EXCLUSIVE rewrite of
page_embeddings; removed the false "IF-EXISTS no-op" claim.
Extends the roundtrip test with idempotent-skip (2nd up() no-op) and env-gate
=false (embeddings rewrite skipped, pages.tsv still swapped) assertions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Asserts the ru_en migration is reversible in the correct order: down()
reverts pages.tsv trigger + page_embeddings.fts to english BEFORE dropping
the ru_en config, and up() re-applies it idempotently.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- search.types.ts: IPageSearch is the #529 superset (pageId/snippet/score/
path/matchedFields/matchedTerms; rank/highlight nullable); add the
IPageSearchResponse pagination envelope and match/limit/offset params.
- search-spotlight.tsx: operator hint ("exact phrase", +required, -excluded).
The web list already reads response.items, so it picks up the new OR order +
superset with no code change; icon/space/highlight remain (acceptance #12).
Full spotlight pagination UI is deferred within this PR — the API + MCP fully
support total/hasMore/offset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- client/read.ts search(): forward match + offset; surface the pagination
envelope (total/hasMore/truncatedAtCap/offset) when the #529 server
returns it (a stock upstream leaves them undefined).
- index.ts search tool: document OR-default + RU/EN morphology, the
"phrase"/+/- operators, match modes, limit+offset pagination and the
relevance-CAP unreachable-tail caveat; add match + offset params.
- server-instructions.ts: READ prose + inventory line updated to the new
contract, enforced by a new tool-inventory.test.mjs assertion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite SearchService.searchPage into ONE engine for web-UI, MCP agent and
share:
- A2 server-side query parser (search-query-parser.ts): quote-aware
tokenizer, leading +/- operators (internal -,.,: literal), "phrase",
metachar-stripped bare terms; tsquery built as a parameterized AST via
SQL ||/&&/!! (never string-concat). only-negation/empty short-circuit.
- A3 match=auto routes identifier-like terms (10.31.41, esp32,
WB-MGE-30D86B) to the substring/trigram branch, words to FTS; word/
prefix/substring overrides.
- A4 RRF (k=60) fuses the FTS branch (ts_rank_cd) and substring branch
(title-exact>title-sub>text tier) by RANK; ORDER BY rrf DESC, id.
- A5 exact permission-filtered total (fail-closed via filterAccessiblePageIds
with the #348 hasRestricted fast-path), CANDIDATE_CAP fusion window,
offset/limit, hasMore, truncatedAtCap.
- A6 single path (spaceId/share/creatorId/titleOnly/parentPageId/match);
share uses getPageAndDescendantsExcludingRestricted.
- A7 response superset per hit (id/pageId/slugId/icon/title/space/…/rank/
highlight/snippet/path/score/matchedFields/matchedTerms).
- A8 buildAncestorPaths now skips deleted + cross-space ancestors.
- A9 DTOs: match, offset, total/hasMore/truncatedAtCap/query/matchedFields.
SEARCH_MODE=or|and toggles the parser; SEARCH_CANDIDATE_CAP tunes the
window. Legacy lookup unit/int specs replaced by parser unit tests + a
13-criteria integration spec on real pg (incl. a permission mutation guard
and a fail-closed propagation test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a `ru_en` FTS configuration (english_stem over ascii token
classes, russian_stem over Cyrillic ones) and flip every stored + query
side to it IN LOCKSTEP (acceptance #13):
- new migration creates ru_en, swaps the pages.tsv trigger + reindexes
existing rows (row-lock UPDATE, no ACCESS EXCLUSIVE), and swaps the
page_embeddings.fts generated column (documented rewrite/lock trade-off
for large tenants, mirroring the #443 trgm migration). down() reverts
tsv/fts to english BEFORE dropping the config (dependency order).
- page-embedding.repo.ts hybridSearch query config english -> ru_en, so
the RAG lexical leg's query config matches its fts column config.
- search.service.ts current query literals english -> ru_en so the column
and query configs stay paired (this commit is independently revertable;
the engine itself is rewritten in the A2-A9 commit).
The reindex is atomic within the single migration transaction (this repo's
Migrator wraps all pending migrations in one tx), so no morphology-desync
window exists and no dual-config read path is needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: pad-clip adjacent different-class sessions at the raw-gap midpoint so
workMs and agentOnlyMs can never double-count the same wall-clock (default
agentTGap 7m < pIn+pOut 10m made a work-session-ending-in-agent overlap a
nearby agent_only run). Reword the config guard/comment: cross-class
disjointness is now structural, tGap≥pIn+pOut is a kept sanity bound. Add a
cross-class no-double-count test and a real seeded property/fuzz test (250
random timelines × 4 tz) asserting per-class union, cross-class disjointness,
and Σ per-day activeMs == workMs.
F2: page.controller.spec — add a ForbiddenException view-gate reject test that
asserts the rejection propagates and computeWorkTime is NOT reached, locking
validateCanView before compute.
F3: WorkTimeStat renders for agent-only pages (workMs==0, agentOnlyMs>0) with
an `agent:` headline so the punch-card stays reachable.
F4: drop the dead un-bucketed `sessions` list from the PageWorkTime response
and the client work-time types (no client reads it; bucketByDay still consumes
the core sessions internally).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The four MCP markdown-write tools (updatePageMarkdown/createPage/patchNode/
insertNode) still described the old parse-everything behavior after #502 turned
TeX math and fuzzy autolink OFF on the write paths. Add a concise contract hint
to each: $...$ / $$...$$ stay literal (not a math formula) and schemeless
www.host / bare emails are not auto-linked (explicit https:// still links); for
a real formula use updatePageJson (or a mathInline/mathBlock node via `node` on
patch/insert). Also note getPage format:"text" in README.md/README.ru.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: the server model-replay loaded history via findAllByChat().map(rowToUiMessage)
WITHOUT hydrating parts from ai_chat_run_steps. A HARD crash mid-run (SIGKILL/OOM)
fires no terminal callback, so the assistant row stays parts:[] and its partial
tool-calls/results/text (durable in the steps table) dropped out of the model's
next-turn context. Hydrate needy assistant rows (role==='assistant' &&
!rowHasInlineParts) via findByMessageIds + hydrateAssistantParts before the replay
map — mirroring the controller's withReconstructedParts exactly — guarded on the
optional repo. Fix the now-false interrupt-resume comment.
F2: add a service int-spec that drives the REAL onStep append-persist WRITE branch
through AiChatService.stream with a real AiChatRunStepRepo injected, asserting the
per-step rows' stepIndex + parts slice and the step-marker metadata match a
single-row flush (catches an stepsPersisted-1 off-by-one).
F3: add a controller int-spec that drives withReconstructedParts through getMessages
WITH the repo present (a mid-run marker-only row + its step rows), asserting the
reconstructed metadata.parts and workspace-scoping.
F4: remove the dead countByMessage (zero prod callers; reconstructRunParts derives
stepsPersisted inline) + its now-unused sql import and the redundant test assertion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The markdown canon lost `hardBreak` nodes in three pm->md->pm sub-cases where
the two-space ` \n` form does not survive re-import via marked:
1. Trailing at end of a paragraph — the top-level `.trim()` strips the trailing
` \n`, and even a non-last paragraph's trailing break sits before the `\n\n`
block gap, so marked drops it.
2. Consecutive breaks (`a<hardBreak><hardBreak>b`) — the whitespace-only middle
line reads as a paragraph separator, splitting the run into two paragraphs.
3. Inside a GFM table cell — the single-line cell collapses the break to a space.
Emit `<br>` (inline HTML break) in exactly those positions: marked passes it
through and generateJSON rebuilds a hardBreak in every context, including table
cells. renderInlineChildren now picks `<br>` for a break that is the last inline
child or is followed by another break; a safe mid-paragraph break keeps the
byte-stable ` \n` form (golden output unchanged). Footnote bodies are skipped
(their `^[…]` form already collapses breaks to spaces). The table-cell case
converts the ` \n` marker to `<br>` before the newline collapse.
Adds pm->md->pm count/position round-trip tests for all three sub-cases plus a
mid-paragraph regression guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Internal review found the two layered-extension flags landed in the SHARED
markdownToProseMirrorCanonical wrapper, which mis-covered two tools. Move the
decision to each caller by the tool's semantics.
BLOCKER 1 — createPage was NOT covered. createPage POSTs to the server
/pages/import endpoint, which imported with DEFAULTS (math + fuzzy autolink ON),
so an agent's `$x=1$` still became a formula and `www.host` still autolinked.
Add an optional `disableMarkdownExtensions` multipart field to /pages/import
(default OFF, so human file uploads keep math ON); import.controller reads it,
import.service.importPage/processMarkdown thread it into markdownToProseMirror.
MCP createPage sends it true.
BLOCKER 2 — import_page_markdown was wrongly disabled. It goes through the same
wrapper, so hardcoding parseMath:false degraded an exported `$x^2$` to literal
text on re-import, breaking the #328 lossless export→import pair. The wrapper no
longer hardcodes the flags: it takes them from the caller and DEFAULTS to the
package importer defaults (extensions ON). Callers now set them explicitly:
- updatePageMarkdown (updatePageContentRealtime) -> OFF
- patch_node/insert_node (importMarkdownFragment) -> OFF (unchanged)
- import_page_markdown -> DEFAULTS (math ON) — #328 round-trip restored
Tests: rewrite the mcp write test around the corrected per-caller semantics
(agent-write OFF, import_page_markdown DEFAULTS incl. the REAL #328 round-trip);
add a collab-backed wiring test driving client.updatePage (OFF) and
client.importPageMarkdown (DEFAULTS) end-to-end; add a createPage multipart-flag
test; add a server import.service.processMarkdown spec (flag OFF -> literal / no
autolink, default -> math ON for human uploads); reword the prosemirror-markdown
round-trip test to its package-default scope. Mutation-verified both caller
wirings (flip updatePageMarkdown -> defaults reddens the OFF wiring test; make
the wrapper default OFF reddens the #328 round-trip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only an agent-sourced idle pulse extends an agent burst; a user idle
(human supervision) now falls through to the human branch so the session
is classified `work`, not `agent_only`. Add @MaxLength(64) to tz to match
its comment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the inline ProseMirror comment-mark clear out of the Undo toast's
onClick and into the reopen mutation's onSuccess branch. Clearing it
eagerly flipped the collab mark to unresolved before the reopen result
was known: on a non-404 failure the RQ cache rolls back to resolved but
the doc kept an active highlight the panel treats as resolved, and a
comment refetch can't heal a divergence that lives in the collab doc.
Mirrors the 404 branch's editor-liveness guard/try-catch and the safe
await-then-mark pattern in comment-list-item. The button-triggered
reopen already sets the mark, so the onSuccess call is an idempotent
no-op there.
Tests: reopen failure (500) leaves the mark untouched; null editorRef
on the success path degrades gracefully.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>