fix: survive third-party DOM mutation from page translation - #3065
Closed
basnijholt wants to merge 344 commits into
Closed
fix: survive third-party DOM mutation from page translation#3065basnijholt wants to merge 344 commits into
basnijholt wants to merge 344 commits into
Conversation
…207 P3 gate) Docker gate on the P3.3 tip caught the stop-emoji spec: after the engine strip removed the viewer's "open thread" context, a redacted reaction on a thread reply landed in ROOM cache scope only. Thread hydration reads THREAD-scope records, so the P1.2 I2 protection (re-applying the cached redaction against stale un-pruned /relations copies and gappy-sync reloads) was silently lost for exactly this case. Root cause: planRedactionCacheCleanup is called with `fallbackThreadId: undefined` from the engine (no UI state). For a pruned reaction, its m.relates_to is redacted away before RoomEvent.Redaction fires, `threadRootId` is often gone, and the redaction event itself is not a thread event. Every hint in the plan's chain (getThreadCacheTargetId → target.threadRootId → redaction.threadRootId) returns undefined, so threadCacheTargetId is undefined and the engine persists to room scope only. Fix at the plan level (chosen over engine-side helper — the plan already receives `room` and it centralizes the entire attribution hint chain in one pure function; the handler stays declarative): when every event-side hint is gone, iterate room.getThreads() and ask each thread's getUnfilteredTimelineSet().findEventById() for the redacted event id. Exactly one hit → use that thread as the attribution with threadTargetFromFallback: false (SDK membership IS the event's attribution, not a viewer-side guess). Ambiguous (>1) or zero hits → keep the previous behavior (by-event-id scan + room-scope persist). Red-first evidence: new tests fail against the unfixed plan (threadCacheTargetId undefined instead of $thread-root; engine persistThreadEventCacheSnapshot called 0 times instead of 1) and pass with the fix. Ambiguous / zero-hit tests already pass, proving existing fallback behavior is preserved. Tests: - redactionCacheLifecycle.test.ts +3 pure-plan cases (single-thread hit / ambiguous multi-thread / zero-thread). - engineWriteThrough.redaction.test.ts (new file, +3 tests): engine-boundary integration — dispatch → plan → deletes + persists, asserting the derived thread id flows through both delete and persist and that ambiguous / zero-hit cases still hit the room-scope fallback. Validation: - npx vitest run src/app/mindroom/engine/ → 60/60 green. - npx vitest run src/app/mindroom/ → 226 files / 1950 tests green. - npm run typecheck clean. - npm run lint → 18 warnings, 0 errors, zero delta from baseline. Co-authored-by: p33-impl (inherited-draft) <noreply@mindroom.chat>
… P3 gate round 2) The round-1 attempt (9a6b15b) modelled the wrong reality. matrix-js-sdk's applyEventAsRedaction calls moveAllRelatedToMainTimeline for non-root thread events, which removes the redacted target from its thread's timelineSet BEFORE RoomEvent.Redaction fires. Round-1's plan-level scan of room.getThreads() therefore found zero hits at fire time and never derived attribution for the stop-emoji case (docker gate re-run confirmed the same failure at spec line 116 on a clean network). Two layers replace it, both stronger than the round-1 scan: Layer 1 (PRIMARY, cache-derived, cannot be defeated by SDK timeline movement): deleteThreadEventFromCacheByEventId now returns Promise<string[]> — the thread scopes it deleted from (deduped, empty on no-match). The engine's redaction handler, when the plan yields no threadCacheTargetId, awaits the walker and persists the redaction tombstone to each returned scope. The tombstone lands precisely where the reaction record physically lived. Layer 2 (SECONDARY hint, pre-prune from the SDK): mindroomSyncEngine now plumbs RoomEvent.Redaction's third arg (threadId, captured pre-prune at room.js:2255 before makeRedacted runs) into EngineLiveEventMeta.sdkThreadId, which the plan honors as sdkThreadIdHint at the top of its hint chain (authoritative, not threadTargetFromFallback). This covers thread MESSAGE redactions (which don't hit layer 1 because there's no reaction record to delete) and any reaction whose redaction reaches the SDK-emit path. The round-1 plan-level SDK-thread-set scan is kept as a harmless leftover for the case where the redacted event IS still threaded at fire time (thread messages never hit moveAllRelatedToMainTimeline; only reactions do). Contract test updated: the walker returns the scope(s) it deleted from (single scope, multiple scopes, and empty on no-match). Engine boundary tests rewritten to model reality — the reaction is NOT in the thread timelineSet at fire time. Plan-level test added for the sdkThreadIdHint priority in the hint chain. Validation: engine + cacheStore vitest 153/153, full mindroom vitest 226 files / 1955 tests, typecheck clean, lint 18 warnings 0 errors (baseline delta zero).
…ken (CINNY-207 P3.3 review) Two defects in the explicit persist point for network back-pagination: - The collection loop walked from the newest end and stopped at the pre-fetch earliest event, so when the SDK extends the timeline in place (the common case) it collected the already-known NEWER events and missed the newly fetched OLDER slice entirely. Now walks from the oldest end and stops at the pre-fetch earliest. - The persist call passed no beforeTokenForEarliest, losing the continuity proof the deleted P1.1 sweep used to write; a reload could not trust cached back-pagination past the new earliest event. The timeline's backward token now flows with the batch (null is meaningful: room-start proof). Red-first: the new persist-point test fails against the prior loop (wrong slice, no token).
…207 P4.1)
Introduce a client-scoped scheduler that will serialize every backfill-
shaped network fetch the app makes: gap fills for Phase 3's tail
discontinuities, thread-open backfills, thread-seed warm-ups, and the
room's own deep-history sweep. Phase 4 lands the queue + dedup + cap
first (this commit) and then wires actual fetch executors on top in
P4.2/P4.3/P4.4.
Invariants:
AC8 dedup: one Map covers queued AND in-flight jobs. Enqueuing a
second (roomId, threadId, kind) while the first is still around
returns the same promise identity — the caller gets the outcome
for free, and the redundant executor is never invoked. Bumps
schedulerDeduped on the rejected duplicate.
Priority + activity: five bands (0=current-room/open-triggered;
1=my-server gap-fills on other rooms; 2=recently-active my-server
tails; 3=thread inventory prewarm; 4=current-room deep history).
Within a band, order by `room.getLastActiveTimestamp()`
descending. At most MAX_CONCURRENT_BACKFILL_JOBS (=2) run at
once — enough to overlap I/O without swamping the server.
Cooperative abort v1: the SDK's `mx.fetchRelations` /
`mx.createMessagesRequest` do NOT accept an AbortSignal today, so
executors receive one and MUST check `signal.aborted` between
batches. Cancellation lands between requests, not mid-request.
Follow-up (recorded in Deviations for the FINAL docs commit):
migrate the raw fetch onto `mx.http.authedRequest({abortSignal})`
once we're comfortable bypassing the SDK helpers.
Engine wiring: `createMindroomSyncEngine` instantiates the scheduler
alongside the write-through + gap tracker, exposes it on the returned
instance, and calls `abortAll()` from `stop()` so account switch /
logout tears down every in-flight fetch.
Observability: four new probe counters (`schedulerEnqueued`,
`schedulerDeduped`, `schedulerAborted`, `schedulerCompleted`) on
`window.__MINDROOM_CACHE_PROBE__` — together the AC8 evidence handle.
Tests: 14 new unit tests in `backfillScheduler.test.ts` covering the
three invariants plus abort semantics (in-flight, queued, unknown key,
`abortAll`), a `pendingJobs()` snapshot for future integration, and
executor error propagation without slot leakage. Full engine test
folder green (77/77); RoomTimeline harness updated (harness engine now
carries an empty scheduler so consumer components that hold a
reference typecheck).
Validation: `npx tsc --noEmit` clean; engine + RoomTimeline vitest
folders 261/261 green.
…Y-219) (#62) * feat(mindroom): timeline minimap with per-human-message stripes (CINNY-219) Port the t3code conversation minimap to the MindRoom timeline: left-edge stripes for every rendered message not sent by a MindRoom agent, with a hover preview card (human message + final agent reply), click-to-jump via handleOpenEvent, keyboard navigation, and scroll-frame in-view tracking. Agent detection combines the platform's mindroom_ localpart prefix with io.mindroom.* content metadata. Fine-pointer devices only; hidden in the compact room overview. See the CINNY-219 Runbook entry in FORK_CHANGES.md. * fix(minimap): address PR #62 review findings - omit aria-valuenow when no stripe is active instead of announcing 0 - make e2e fixture passwords env-overridable (E2E_HUMAN_PASSWORD/E2E_AGENT_PASSWORD) - range-based in-view tracking: a stripe stays lit while scrolling through its (possibly viewport-tall) agent reply, not only while the question itself intersects the viewport - re-run the in-view pass on container/content resize (window resize, collapsible expand, media load) via ResizeObserver - stop pairing an agent reply across a redacted human question; redacted non-agent messages terminate the pairing run (regression tests added) * refactor(minimap): apply review nits - track (pointer: fine) via the media query's change event (same pattern as useSystemThemeKind) so plugging in a mouse enables the minimap without a room/thread switch - build fixture login JSON with json.dumps so overridden passwords with quotes or backslashes can't break the request body
Land the second half of the F13 tier plan: the executor that turns
the Phase 3.2 gap-fill queue from an enqueue-only detector into an
actual "message a room missed while offline reaches the cache after
restart" path, and the D3 prefetch policy that keeps every future
speculative fetch friendly to remote homeservers.
New modules:
engine/prefetchPolicy.ts — D3 policy. `resolveRoomPrefetchTier`
compares the room's `m.room.create` sender domain to
`mx.getDomain()`; matches → `own`, differs → `federated`,
missing → `background`. Never parses room ids (room v12 / MSC4291
room ids are opaque — this is deliberate). `isRoomEligibleForRawFetch`
is the raw-fetch gate: rejects federated + encrypted + background
rooms. Constants (`ROOM_TAIL_PREFETCH_DEPTH`,
`THREAD_INVENTORY_PREFETCH_LIMIT`,
`CURRENT_ROOM_DEEP_HISTORY_TARGET`) live here so the scheduler
is the single arbiter of depth.
engine/gapFillExecutor.ts — over the P4.1 `BackfillScheduler`.
Subscribes to the gap tracker's queue via the new `onEnqueue`
hook, so a fresh `RoomEvent.TimelineReset` -> `limited-sync` enqueue
dispatches immediately. Executor uses `mx.createMessagesRequest`
(Direction.Backward, 200/batch, up to 20 iterations) — the first
caller of that SDK method in this fork (Deviations §8). Persists
each chunk via `saveRoomEventsToCache`, then clears the durable
`tailDiscontinuity` marker on success (persisted anything OR
server confirmed no more history). On abort or transport error
the marker stays put so the next boot retries. Federated rooms
are short-circuited BEFORE the scheduler sees them; encrypted
rooms pass the tier gate but the raw-fetch gate rejects and
clears the marker (they're never usable via /messages without
decryption context).
New cacheStore surface: `noteRoomFederated(sessionId, roomId, flag)`
in `cacheStoreLedger.ts` — patch-only setter for the ledger's
`federated` field. Existing rows: single-field replace, other
counters untouched (so LRU-inside-priority ordering the eviction job
depends on is preserved). Missing rows: minimal bootstrap
(`approxBytes=0`, `eventCount=0`, `lastActivityTs=0`, `federated`)
so the flag survives until the events store's ledger tracker fills
in real deltas. Fills the P2.2-deferred writer gap.
Engine consolidation: `mindroomSyncEngine` grows a `noteRoomFocused`
method exposed on the returned instance. Bookkeeping executed per
call: resolve tier (skip stamp for `background`), `noteRoomFederated`,
`setEvictionProtectedRoomIds([roomId])` (single-element v1 —
Deviations §8: LRU inside priority covers everything else),
`noteRoomOpened` + `noteThreadOpened` for lastOpenedTs. MindroomRoomTimeline
calls it from a useEffect keyed [syncEngine, room.roomId, threadId]
sitting under the enginePersistForRoom useMemo.
E2E spec tightened (AC13, team-lead-approved divergence-3): the P3.2
red version sent ONE offline message and slept — Tuwunel rarely
declares `limited=true` for that single event, so the fill path only
sometimes ran. The green version sends ~25 REST messages while the
page is still mounted, reloads, resets the probe on the fresh page,
waits 12s, and asserts (a) `schedulerCompleted >= 1`, (b)
`gapFillsEnqueued >= 1`, (c) the last REST event id is present in
the room event cache. `test.fail()` removed.
Engine gap tracker: `GapFillScheduler` now exposes
`onEnqueue(listener)` returning an unsubscribe. The in-memory
scheduler fires listeners in a try/catch so a misbehaving subscriber
can't break the tracker.
Architecture guard updated: `mindroomSyncEngine.ts` and
`gapFillExecutor.ts` join `engineGapTracker.ts` on the cacheStore-
consumer allowlist. Both are engine-native cache orchestrators
(D2) — routing through eventRepository would be a gratuitous
pass-through.
Test harness: `MindroomSyncEngine` mock in
`RoomTimeline.test.shared.ts` and `RoomTimelineCollapsible.test.ts`
now includes `noteRoomFocused: () => undefined` and
`scheduler: createBackfillScheduler()` so consumers using either
don't crash.
Tests: 4 new suites, 24 tests total.
- prefetchPolicy.test.ts (10): tier resolution across own /
federated / port-suffixed / missing-create / malformed-sender /
unknown-our-domain; eligibility across encrypted / federated /
background.
- gapFillExecutor.test.ts (5): drains a queued limited-sync job
and clears the marker; skips federated rooms (marker preserved);
skips encrypted own-server rooms (marker cleared); subscribes to
future enqueues; leaves the marker on network failure.
- noteRoomFocused.test.ts (4): federated=false for own-server;
federated=true for other-server; no stamp for background;
single-element protection replacement.
- E2E cinny207-gap-fill-restart tightened (still docker-gated).
Validation: engine + threads folders 373 tests passing (added 24 net
new); full mindroom suite 230 files / 1988 tests green; typecheck
clean.
…b (CINNY-207 P4.3)
Retire `useRoomEagerPreload` and its whole render-facing progressive
recalibration dance. Deep-history sweep is now a band-4 job on the
engine's `BackfillScheduler` that fetches `/messages` via
`mx.createMessagesRequest` and persists straight through
`saveRoomEventsToCache` — the SDK live timeline is never mutated by
the sweep, so React stops paying the recalibrate cost per batch and
the user gets bottomless scrollback on next cache hydration.
Motivations (from the plan):
- F13: the old loop was unbounded eager preload, which is why very
active rooms would OOM the tab.
- D14: prefer the "persist raw events to IDB, let hydration read on
demand" pattern over "grow the SDK's in-memory timeline forever".
- AC8: the scheduler's dedup key
(roomId, undefined, 'room-deep-history') means view-mode flips,
thread open/close, and the countless RoomTimeline re-mounts
triggered by focus/filter changes NEVER fire redundant deep-history
sweeps.
New module: `engine/deepHistoryJob.ts`.
`enqueueRoomDeepHistoryJob({mx, sessionId, scheduler, roomId,
targetEventCount?})` submits a band-4 job. The executor:
1. Skips encrypted / federated / background rooms via
`isRoomEligibleForRawFetch` (same policy as gap-fill).
2. Reads the room's live-timeline backward token as the starting
from-token (matches the old preload's saved-token restoration).
3. Loops `mx.createMessagesRequest(Direction.Backward, 200/batch)`
until reaching CURRENT_ROOM_DEEP_HISTORY_TARGET (10000 events),
the server signals no more history (end===undefined), the token
is stuck (same token twice), or the AbortSignal fires.
4. Persists each chunk via `saveRoomEventsToCache`.
5. Yields to a macrotask between batches so a long sweep can't
starve UI callbacks.
Deletions:
- `src/app/mindroom/threads/preloadController.ts` — the whole hook
plus its recalibrate + stall-guard state machine (317 lines).
- `[eagerPreloading, setEagerPreloading]` React state in
`MindroomRoomTimeline`; the reset useLayoutEffect; the
`eagerPreloadDoneForRoomRef` and its handoff to
`useRoomCacheHydrationController`; the `!eagerPreloading` term in
the skeleton gate at the top of the room timeline (skeleton now
relies on cache/live counts alone).
- `eagerPreloading` prop on `useTimelineDebugRangeController` and
the debug log entry that carried it (mirror test updated).
- `roomCacheHydrationController` no longer takes the preload-done
ref or the eagerPreloading setter; the `.finally()` block that
used to clear the loading flag on re-entry is gone.
Wiring:
- `MindroomRoomTimeline` calls `enqueueRoomDeepHistoryJob` from a
useEffect keyed [eventId, mx, room.roomId, roomEagerPreloadEnabled,
sessionId, syncEngine, threadId]. Fire-and-forget; the scheduler
dedupes and `syncEngine.stop()` aborts on account switch.
- Engine barrel exports `enqueueRoomDeepHistoryJob` and
`EnqueueDeepHistoryArgs`.
Architecture guards updated:
- Rewrote the "delegates eager room preload orchestration outside
RoomTimeline" guard in `RoomTimeline.architecture.test.ts`. It now
asserts (a) `useRoomEagerPreload` is NOT present in RoomTimeline,
(b) `enqueueRoomDeepHistoryJob` IS present.
- New guard "keeps backfill network fetchers inside the engine":
RoomTimeline must not call `mx.createMessagesRequest` directly.
- `deepHistoryJob.ts` joins the cacheStore-consumer allowlist next
to `gapFillExecutor.ts` (engine-native cache orchestrator).
Deviations (recorded in the FINAL docs commit):
- Progressive-render recalibration NOT replicated. Old behavior:
scrollbar height grew smoothly as each batch landed. New behavior:
events land in IDB; the next mount / cache-hydrate pass surfaces
them at once. Cost: users don't see the "loading more" scroll
smear anymore. Benefit: no per-batch React re-render storm.
- Cooperative abort v1 (same as backfillScheduler.ts): the executor
checks `signal.aborted` between batches, not mid-request.
Tests:
- deepHistoryJob.test.ts (4): drains the request loop until target
reached; skips encrypted rooms; deduplicates concurrent enqueues
(AC8); starts from the room's live-timeline backward token.
- Deleted "keeps eager-preloading past fifty batches" from
RoomTimeline.cache.test.ts — it asserted the deleted preload
loop's iteration count on `mx.paginateEventTimeline`; the
engine-side behavior is covered by the new deepHistoryJob suite.
- timelineDebugController.test.ts updated to match the trimmed
prop shape.
Validation: full vitest 337 files / 2553 tests green (up from 2549
after P4.2); typecheck clean.
…eduler (CINNY-207 P4.4)
Route the two remaining backfill-shaped fetches through the engine's
BackfillScheduler so AC8 dedup applies to them the same way it
already applies to gap-fill and deep-history:
- ensureThreadSeedPrewarm now wraps its cache-first seed load in
`syncEngine.scheduler.enqueue({kind: 'thread-seed', priority: 3})`.
The per-controller `prewarmingThreadSeedPromisesRef` map used to
be the F9 dedup point, but it only deduped WITHIN a single
MindroomRoomTimeline mount — concurrent controllers or a remount
would re-fire the same seed load. Client-scoped scheduler dedup
guarantees at most one in-flight `loadThreadCachedSnapshot` +
`mergeThreadBackfillEvents` for a given (room, thread). The
controller-local refs are still populated for downstream
consumers (e.g. `startUntargetedSeedPrewarmWait` in
threadOpenSeedController reads the map to decide whether to
await); they now mirror scheduler state instead of owning it.
- refreshOverviewThreadCacheFromRelations now wraps its
`fetchAllThreadRelations` + persist call in
`syncEngine.scheduler.enqueue({kind: 'thread-backfill', priority: 2})`.
Two overview-resume signals (visibility + focus firing in quick
succession, or focus + online during a hop), or two independent
resume callbacks for the same thread from different producers,
now share the fetch. `overviewResumeRefreshInFlightRef` and
`pendingOverviewResumeRefreshRef` are deleted; the 1s
rate-limit on the outer trigger stays (it's a burst-fire guard
on the OUTER controller, not per-fetch dedup).
Priority selection:
- band 2 for thread-backfill: overview resume is user-triggered
(page focus / online / visibility) so it beats prewarm (band 3)
but yields to the current room's own gap-fill (band 0-1).
- band 3 for thread-seed: inventory prewarm; yields to interactive
work and to the current-room gap-fill, but runs ahead of the
band-4 deep-history sweep so a room open followed by a thread
open stays snappy even mid-sweep.
The two controller files keep their outer React shape (state,
generation guards, priority-target drain loop) — this commit does
not rip out orchestration, only relocates fetch dedup onto the
scheduler. That preserves every downstream consumer's API
(prewarmedThreadSeedIdsRef, prewarmingThreadSeedIdsRef, etc.) so
threadOpenSeedController and threadOpenLifecycleController don't
need to change in this commit. The remaining
threadSeedPrewarmController scaffolding is a candidate for further
simplification in a follow-up phase (P5+), where seed jobs could be
enqueued directly from `engine.noteRoomFocused` — recorded as a
Deviation for the FINAL docs commit.
Behavior:
- No functional change users can see. Idle refresh count is
strictly lower (dedup) rather than higher. Every existing test
covering these controllers still passes because the callbacks'
outer shape and effect ordering are preserved.
Validation: full mindroom vitest 231 files / 1992 tests green;
typecheck clean.
Wraps `cache-overhaul/11-p4-scheduler` with the runbook, plan
updates, scorecard rows for AC8 + AC13, and the seven Deviations
entries for the design choices team-lead approved in the GO-P4
briefing.
Content:
- FORK_CHANGES.md: full "Phase 4 - BackfillScheduler + prefetch
policy" runbook entry ahead of the P3 gate entries. Lists the
four feature commits with the file-level shape, the AC8 + AC13
scorecard status, and the validation numbers.
- docs/mindroom-cache-overhaul-plan.md:
- Header status: Phase 4 landed locally; Phase 5 next.
- Section 5: Phase 4 phase bullets tagged "landed 2026-07-04"
with the four commit SHAs.
- Section 6.3 Scorecard: AC8 marked `☐ impl` with the exhaustive
evidence trail (backfillScheduler unit tests + deepHistoryJob
+ gapFillExecutor dedup, plus the P4.4 controller-level
integration). AC13 marked `☐ impl` with the executor design,
the tightened AC13 spec shape, and the docker-gate handoff
note.
- Section 6.4 Regression guards: extended the cacheStore-
consumer allowlist with the four engine-native cache
orchestrators; added the new "backfill network fetchers live
inside the engine" guard from P4.3.
- Section 8 Deviations: seven new entries covering cooperative
abort v1, `createMessagesRequest` as the raw-fetch primitive,
`noteRoomFederated` as a patch-only setter, AC13 spec
tightening, single-element eviction protection v1, no
progressive-render recalibration for the deep-history job,
and the "P4.4 keeps controller scaffolding" note.
- Section 9 Status log: Phase 4 landing entry with the full
commit list, validation numbers (2553/2553 vitest, 18-warning
lint zero delta), and the docker-gate handoff.
- Lint zero-delta cleanup:
- `engine/deepHistoryJob.ts`, `engine/__tests__/gapFillExecutor.test.ts`,
`engine/__tests__/noteRoomFocused.test.ts` — wrap the
`new Promise((r) => setTimeout(r, N))` pattern in a block-body
executor so the `no-promise-executor-return` rule stops
counting it as returning the setTimeout id. Behavior identical.
- `threads/roomCacheHydrationController.ts` — restore `setAtBottom`
to the effect dep list I trimmed in P4.3 (the effect calls it
unconditionally, so the exhaustive-deps rule wanted it back).
Validation (final gate):
- `npx tsc --noEmit` clean.
- `npx vitest run` full 337 files / 2553 tests green.
- `npm run build` OK.
- `npm run lint` 18 warnings / 0 errors — matches the P3 baseline
exactly (zero delta).
Docker gate: NOT run by the implementing agent. Team-lead runs the
AC13 flip (green-shape) + the regression trio (stop-emoji,
streamed-edit, background-freshness) after PR open.
…' into cache-overhaul/11-p4-scheduler
First docker gate on the Phase 4 branch reported AC13 (cinny207-gap-fill-restart) failing at spec:130 with `schedulerCompleted >= 1` receiving 0, while the earlier `gapFillsEnqueued >= 1` assertion passed. That snapshot is ambiguous between "silent job failure" and "policy skipped before scheduler saw it" — exactly what made debugging from a probe snapshot hard. Three converging fixes land here. 1. `schedulerFailed` probe counter, bumped from the scheduler's non-abort reject branch. Previously a non-abort executor throw counted as neither `schedulerCompleted` nor `schedulerAborted`; the counter shape couldn't distinguish "job silently rejected" from "job never ran". The AC13 spec now asserts `schedulerFailed === 0` with a diagnostic message pointing at `createMessagesRequest` / `saveRoomEventsToCache` so the next gate iteration doesn't need log spelunking. 2. `gapFillExecutor.enqueue` no longer short-circuits on tier. Every tracker enqueue now enters the backfill scheduler so `gapFillsEnqueued` and `schedulerEnqueued` stay in lockstep. The `isRoomEligibleForRawFetch` gate still fires inside `runOnce` with the same net behavior (no /messages call for federated / encrypted rooms) but the completion counter bumps. Marker semantics preserved by tier inside `runOnce`: encrypted-own clears the marker (unusable ciphertext, no retry point); federated preserves the marker (Deviations §8 policy — handled by user attention, not background sweeps). 3. AC13 spec's probe reset moves from `page.evaluate(() => probe.reset())` after `expectLoggedInShellStable` to `page.addInitScript` BEFORE the reload. The old order zeroed the probe AFTER the post-reload engine had already primed, enqueued startup jobs on Sync→PREPARED, and quite possibly completed some — so any `schedulerCompleted` bumps during that window were wiped and the assertion relied on a NEW enqueue in the following 12s (racy on a well-caught-up sync). Init-script runs on the fresh document before app JS mounts; the probe module installs itself on import and the script resets it via microtask + rAF fallback so every counter delta is attributable to the post-reload engine. Tests: +2 scheduler (`schedulerFailed` counted on non-abort reject; abort-caused reject still counts as `schedulerAborted`, not `schedulerFailed`). +1 gapFillExecutor red-first AC13 mechanism (startup job for own-server room with `prevBatch=undefined` still enters the scheduler and completes: `schedulerEnqueued=1, schedulerCompleted=1, schedulerFailed=0`, with the SDK call landing on `fromToken=null`). Federated-room test updated for the new lockstep behavior (marker preserved, no /messages call, but `schedulerEnqueued=1, schedulerCompleted=1`). Validation: `npx tsc --noEmit` clean; `npx vitest run` 337 files / 2557 tests green (+4 vs the Phase 4 baseline 2553); `npm run build` OK; `npm run lint` 18 warnings / 0 errors (exact baseline, zero delta).
… P5.1) Introduces `engine/reconciler.ts` with `scheduleReconcile()` — the P5 enforcement point for D7: coverage decides paint, never revalidation. Every thread open now schedules exactly one reconcile pass through the P4.1 BackfillScheduler with a new `'reconcile'` kind at band 0. When cache matches server truth the pass is a cheap no-op (fetch, diff empty, no writes, no tick — the D7 "cached was right" path). When they diverge the P1.2 machinery (hydrateCachedEvents → applyCachedRedactions, applyCachedReplaceRelations, reconcileRelationEventsWithAggregation) applies missed edits, missed redactions, and aggregation cleanup in place, and fires a single batched onRepaired tick so the render layer picks up the change without per-repair flicker. Removes finding F7's 200-event ceiling: the pre-P5 `refreshLatestThreadRelationsTail` capped at a single limit-200 batch, so a divergence deeper than 200 events never converged after open. The reconciler pages further until either the fetched chunk overlaps the cached window by event id, the SDK signals no-more, or the abort signal fires (bounded by MAX_RECONCILE_ITERATIONS=25 for parity with `fetchAllThreadRelations`). D7 rewire in `threadOpenCacheFirst.ts`: the complete-coverage path schedules the reconcile via a new `scheduleReconcile` prop instead of firing the now-deleted `refreshLatestThreadRelationsTail`. The partial-coverage path in `threadOpenLifecycleController.ts` also schedules one after `runThreadOpenSdkBootstrap` — every open, without exception (AC9). The scheduler dedups so both paths firing on the same open produce one round-trip. Deletes `refreshLatestThreadRelationsTail` from `threadOpenCacheController.ts` and its type; the arch guard at `RoomTimeline.architecture.test.ts:754` stays as a tripwire against reintroducing it as a component-local useCallback. The controller also drops the `roomTimelineSet` param that was plumbed through only for the deleted method. Tuwunel stale-copy handling: the reconciler funnels every fetched raw event through `createPreferLiveEventMapper` (P1.2 F6-B). If a raw event carries `unsigned.redacted_because` and the SDK live instance doesn't yet know it's redacted, `makeRedacted` cascades into the SDK's `Relations.BeforeRedaction` listener and removes the stale reaction chip — the same invariant I2 machinery that P3-gate landed. AC2 spec added RED (`test.fail`) at `e2e/live/cinny207-stale-cache-divergence.spec.ts`: seed a thread with M (edit target v1), R (redact target), and a 👍 reaction; close the client; edit M to v2, redact both the reaction and R plus 25 filler thread messages via REST; reopen the thread URL (no reload); assert `v2 converged` visible, no 👍 chip, R tombstoned, cache converged, and the mid-viewport anchor stays within 8px through the repair. The filler is what forces the reconciler (not Tier-1 write-through) to be the one that converges the cache. Commit 4 hardens the applier and flips this green. Validation: `npx tsc --noEmit` clean; `npx vitest run` 338 files / 2563 tests green (+6 vs the Phase 4 gate-fix baseline 2557); `npm run build` OK; `npm run lint` 18 warnings / 0 errors (exact baseline, zero delta).
…esh (CINNY-207 P5.1)
Moves the `/relations` fetch boundary fully into the engine and
deletes `threadOpenPostBootstrapRefresh.ts` — its limit-200 fetch is
now the reconciler (Commit 1), its forward-gap check + log string
move inline into `threadOpenLifecycleController.ts`, and its
shouldScrollToLatestOnOpen → refreshLatestThreadSlice branch also
moves into the lifecycle controller. The `'thread-open-forward-gap-check'`
log string is preserved from the new home so existing capture
consumers keep working.
New `engine/threadRelationsFetcher.ts` owns the `fetchAllThreadRelations`
paging fetcher (migrated from `threadBootstrap.ts`) plus the
`MAX_THREAD_FETCH_EVENTS` / `MAX_THREAD_FETCH_ITERATIONS` caps.
`threadBootstrap.ts` re-exports the engine symbols for the existing
unit-test surface (`threadBootstrap.test.ts` still covers the fetcher
end-to-end via the facade).
New `engine/threadBackfillJob.ts` is a thin scheduler-job producer:
`enqueueThreadBackfillJob({mx, scheduler, room, threadId, priority,
shouldContinue})` routes the fetch through the P4.1 BackfillScheduler
under the existing `'thread-backfill'` kind (P4.4's overview-resume
dedup domain — a user-triggered open and a background resume for the
same thread coalesce). The React-side render-state work
(setSupplementalThreadEvents, saveThreadOpenSeedSnapshot,
persistThreadEventCache, setThreadTailLoaded, forceTimelineUpdate,
tick) stays in `threadOpenCacheController`; only the network side
lands in the engine.
`threadOpenCacheController.backfillThreadRelationsIntoCache` now
awaits `enqueueThreadBackfillJob(...)` instead of calling
`fetchAllThreadRelations` directly. The controller gains a
`scheduler: BackfillScheduler` prop plumbed from
`MindroomRoomTimeline.tsx` (via `syncEngine.scheduler`).
`threadOverviewResumeController.ts` retargets its
`fetchAllThreadRelations` import at `../engine` (was
`./threadBootstrap`) — it stays a legitimate non-engine consumer of
the fetcher for now (a full engine-side rewrite of overview-resume is
a future refactor); the arch guard's non-engine importer allowlist
lists it explicitly.
Two new architecture guards land in
`engine/__tests__/engine.architecture.test.ts`:
1. `fetchAllThreadRelations is defined in engine/, and imported
only within engine/**` — accepts `threadBootstrap.ts` as the
re-export facade and `threadOverviewResumeController.ts` as the
lone non-engine consumer. A third caller trips the guard.
Explicitly out of scope: `notifications/readReceipts.ts` uses
`mx.fetchRelations` with a `RelationType.Thread` limit-1 receipt
probe — receipts-domain, not thread-history backfill.
2. `mx.fetchRelations in threads/ is limited to threadOpenSdkBootstrap.ts
with exactly 2 occurrences` — the two limit-50 fallback SDK
bootstraps stay; a third `mx.fetchRelations(` call in that file
or any new caller elsewhere in `threads/` trips the guard.
Existing `RoomTimeline.architecture.test.ts` post-bootstrap-refresh
guard is reshaped: the file's runner is deleted, so the guard now
asserts the forward-gap check + log string live in the lifecycle
controller (rather than in a separate refresh module).
Validation: `npx tsc --noEmit` clean; `npx vitest run` 337 files /
2563 tests green (-1 file from the deleted `threadOpenPostBootstrapRefresh.test.ts`,
which the reconciler + backfill-job units + arch guards cover
equivalently); `npm run build` OK; `npm run lint` 18 warnings / 0
errors (exact baseline, zero delta).
Wires a room-scope reconcile pass onto the engine's `noteRoomFocused`
so every room focus enters the P4.1 scheduler under
`kind: 'reconcile'` with `threadId: undefined`. Completes the D7
"every open schedules exactly one reconcile" invariant at both
scopes: thread opens land as thread-scope reconciles (Commit 1), and
room opens land as room-scope reconciles.
The room-scope executor is intentionally a fast no-op. Room-open tail
catchup is already end-to-end owned by two engine-owned producers
wired in earlier phases:
- `RoomEvent.TimelineReset` (P3.2) writes the durable
`tailDiscontinuity` marker and enqueues a `'limited-sync'`
gap-fill job.
- `Sync -> PREPARED` (P3.2) enqueues a per-room `'startup'`
gap-fill job for each joined room with the marker set.
The P4.2 gap-fill executor consumes both queues, drives a
`mx.createMessagesRequest` catchup, persists via
`saveRoomEventsToCache`, and clears the marker on completion. What
Commit 3 adds at the room scope is the SCHEDULE tripwire: probe
counters bump (`schedulerEnqueued` +1 per room open), and a capture
can prove no room-open silently short-circuits away from the engine.
Running a second /messages catchup here would duplicate the gap-fill
executor's work, so the room-scope reconcile executor is deliberately
inert — it logs `reconcile-complete` with `note: 'room-scope
reconcile — tail catchup owned by gap-fill executor'` and returns
`repaired: false, fetchedCount: 0, iterations: 0`. Because it never
repaired anything, `onRepaired` is intentionally NOT called
(preserves the invariant "onRepaired fires only when a repair was
actually applied").
Dedup keys: AC8's `(roomId, threadId, kind)` key means a room-scope
reconcile (threadId=undefined) and a thread-scope reconcile
(threadId=$thread) on the same room map to different keys and coexist
by design. Repeated `noteRoomFocused` calls (rerender storms, quick
tab switches) collapse to one job via the standard AC8 dedup path —
the second call returns the in-flight promise identity, bumps
`schedulerDeduped`.
Unit tests added to `reconciler.test.ts`:
- Room-scope pass enters the scheduler with kind `'reconcile'` +
threadId `undefined` + band 0, does not fetch, does not fire
onRepaired.
- Room-scope pass dedups against another room-scope schedule for
the same room.
- Room-scope and thread-scope reconciles on the same room coexist
(different dedup domains).
Validation: `npx tsc --noEmit` clean; `npx vitest run` 337 files /
2566 tests green (+3 vs Commit 2's 2563 for the new room-scope
units); `npm run build` OK; `npm run lint` 18 warnings / 0 errors
(exact baseline, zero delta).
… unit (CINNY-207 P5.2) Two new reconciler unit tests plus the AC2 spec flip. The applier's in-place invariant (AC10) and the Tuwunel stale-copy re-apply path (P3-gate work) are now covered end-to-end. Applier hardening unit: `applier hardens against prepends: repairs only swap or delete existing ids + append at the tail (AC10)` Fixture: a fetched page carrying a bundled edit on a cached target AND a redaction event targeting a different cached target. Asserts the reconciler treats both as divergence, fires onRepaired exactly once, and mutates via `hydrateCachedEvents` (which uses SDK `makeRedacted` / `makeReplaced` — instance mutation, not array splice). The reconciler deliberately does NOT push to `setSupplementalThreadEvents`; the render layer picks up the mutation on the batched tick. That's the AC10 guarantee: no prepends, no length changes, no anchor drift. Tuwunel stale-copy re-apply unit: `Tuwunel stale-copy re-apply: a fetched page carrying unsigned.redacted_because for a cached target reapplies the redaction via prefer-live mapper` Fixture: `mx.getRoom(...).findEventById($reaction)` returns a live SDK-managed instance with a tracked `makeRedacted` spy. The fetch returns the reaction event with `unsigned.redacted_because` — the exact Tuwunel behavior we discovered empirically in the P3 gate work (docker Tuwunel serves un-pruned redacted events on /relations for ~10s after redaction). Asserts the reconciler funnels the raw event through `createPreferLiveEventMapper` before diffing, so the live instance gets the redaction re-applied and the SDK's `Relations.BeforeRedaction` listener cascades reaction chip cleanup. Invariant I2: our own record of server truth drives convergence; a stale server copy cannot un-repair a fresh redaction the client already knew about. AC2 spec flipped GREEN: `e2e/live/cinny207-stale-cache-divergence.spec.ts` loses its `test.fail` guard. The docker gate against real Tuwunel is the team-lead's to run; the applier + prefer-live mapper wiring is covered by the new unit tests in the meantime, so the spec correctly describes the expected converged state. Validation: `npx tsc --noEmit` clean; `npx vitest run` 337 files / 2568 tests green (+2 vs Commit 3's 2566 for the new applier + Tuwunel stale-copy units); `npm run build` OK; `npm run lint` 18 warnings / 0 errors (exact baseline, zero delta).
FORK_CHANGES.md Runbook: prepends a Phase 5 entry covering P5.1
Commits 1-3 and P5.2 Commit 4 with per-commit file lists (new /
modified / deleted), the D7 rewire diff, the arch guard changes, the
AC2 spec design, the P5.1 Commit 2 deviations (leaner producer split
+ overview-resume non-engine allowlist entry + readReceipts scope
exclusion), the followups (move overview-resume fully into engine;
thread AbortSignal through fetchRelations when the SDK grows one),
and the full validation numbers.
docs/mindroom-cache-overhaul-plan.md:
- Phase 5 section header flipped to `landed 2026-07-04` with the
four feature commit hashes and a per-commit summary.
- §6.3 scorecard AC2 (☐ impl — reconciler + spec design covered,
docker gate pending); AC9 (✓ — 11 reconciler unit tests + the
D7 wiring at threadOpenCacheFirst.ts:114 and lifecycle
controller partial-coverage schedule); AC10 (✓ — existing anchor
suites stay green, new correction-path applier hardening unit,
AC2 spec 8px assertion). AC2 references the docker gate as the
team-lead's responsibility per plan §7.
- §6.4 regression guards: adds the two new engine-owned
`/relations` boundary guards (fetchAllThreadRelations
engine-only, mx.fetchRelations in threads/ limited to
threadOpenSdkBootstrap.ts with exactly 2 occurrences), notes on
the retained `refreshLatestThreadRelationsTail` tripwire, and
the reshaped post-bootstrap guard pointing at the lifecycle
controller as the new home of the `'thread-open-forward-gap-check'`
log string.
- §9 status log: full Phase 5 entry with all four feature commits
described end-to-end and validation numbers.
Validation: `npx tsc --noEmit` clean; `npx vitest run` 337 files /
2568 tests green (identical to the P5.2 Commit 4 tip — this is a
docs-only commit); `npm run build` OK; `npm run lint` 18 warnings /
0 errors (exact baseline, zero delta).
…/repaired probes (CINNY-207 P5-GATE-FIX) AC2 gate root cause: mx.fetchRelations is a pure HTTP call and does NOT push events into the SDK thread model. The reconciler mapped and hydrated events but never called `thread.addEvents`, so useThreadRenderState (which reads `thread.events`) never saw the fetched m.replace edit — the reopen render kept painting v1 even after the reconcile pass completed. The pre-P5 runThreadOpenPostBootstrapRefresh (deleted in 05594b5) called `currentThread.addEvents(latestEvents, false)`; P5 dropped it. Fix: on divergence, before hydration and the onRepaired tick, inject allMapped into `room.getThread(threadId)?.addEvents(events, false)`. The `false` argument matches the reconciler's Backward-from-HEAD pagination direction (tail end of thread). SDK dedupes on event_id so re-injecting known events is a no-op. Skipped entirely on the D7 no-op path to preserve the "cached was right = zero cost" guarantee. Also verified empirically that docker Tuwunel DOES honor MSC3981 recurse=true (a threaded m.replace on M appears in /relations of the thread root when recurse is set) — so the earlier ranked hypothesis about missing recurse support is disproved. Observability: adds reconcilesScheduled and reconcilesRepaired probe counters. Bumped in scheduleReconcile (thread-scope + room-scope) and at the repair-applied point respectively — same lesson as schedulerFailed from the P4 gate fix (without them, trace analysis can't distinguish "never scheduled" from "scheduled, empty diff"). Tests: 3 new red-first units in reconciler.test.ts assert the counters bump correctly, that the SDK injection happens with the right shape on divergence, and that it does NOT happen on the D7 no-op path. Verified red without the fix (stash + rerun: 3 failures), green with the fix (15/15). Validation: typecheck clean, 2572/2572 tests pass, build OK, lint holds exact 18-warning baseline (0 errors).
… clones (CINNY-207 P5-GATE-FIX v2) The v1 fix (4aa3c19) added thread.addEvents injection but AC2 still failed on tip 4aa3c19 (docker Tuwunel gate). Team-lead disproved the RECURSE hypothesis empirically via curl (server does return the m.replace with recurse=true) and identified the true root cause: instance race on the complete-coverage cache-first path. Root cause: - useThreadRenderState in 'live' mode reads fallbackEvents populated by setSupplementalThreadEvents, which holds the exact MatrixEvent clones hydrateThreadFromCache built from the cache. - The reconciler was re-mapping cachedPage.events (raw JSON) via mapCachedThreadPageEvents, producing a SECOND set of clones. - applyCachedReplaceRelations mutated the second clones via makeReplaced; the render kept holding the first set unchanged. - The onRepaired tick re-ran the render, saw unchanged instances, painted v1 forever. Fix: - Extend HydratedThreadCachePage with hydratedEvents / hydratedRootEvent (optional MatrixEvent arrays). hydrateThreadFromCache populates them with the same instances passed to setSupplementalThreadEvents. - Reconciler resolveCachedSnapshotEventsForRepair helper prefers those instances; consults room.findEventById directly for the P1.2 both-ways-heal case (SDK has a newer live copy). Guarded so the preferLive mapEvent fresh-clone fallback never wins over the render's instance. - Retain thread.addEvents(allMapped, false) from v1 (SDK Thread model still needs the new edit event for its own downstream listeners). Red-first: new reconciler.test.ts unit models the detached-render shape (renderHeldEditTarget with own vi.fn() makeReplaced spy passed via cachedPage.hydratedEvents; fetched chunk is a standalone m.replace targeting $edit-target). Assertion fails when the reconciler re-maps to fresh clones (count 0), passes only when it operates on the render's instance. Validation: - typecheck: clean. - vitest: 337/337 files, 2573/2573 tests. One intermittent noteRoomFocused flake on the first full run; passed in isolation and on the second full-suite run. Documented as pre-existing fake-indexeddb cross-test flake, unrelated to this diff. - build: OK. - lint: 0 errors, 18 warnings — exact baseline, zero delta. Observability retained: reconcilesScheduled/reconcilesRepaired probes from v1 still bump identically. Not pushed. Docker AC2 re-run is team-lead's.
…INNY-207 P5 gate diagnosis)
…pplementalThreadEvents (CINNY-207 P5-GATE-FIX v3)
Team-lead refinement of v2: v2 was necessary but not sufficient. The
instance-race patch made the P1.2 hydration pipeline mutate render-held
clones (correct on the cache path), but on complete-coverage cache-first
the SDK bootstrap is skipped by design, and the SDK's own timeline set
was never told about the fetched m.replace event. Any downstream SDK
listener re-reading thread.replacingEvent via the SDK path would still
see v1.
Do BOTH injections:
1. Engine keeps the SDK-side leg: liveThread.addEvents(allMapped, false)
(already in v1) plus the v2 cache-instance identity fix.
2. Engine widens onRepaired to
(repairedEvents: readonly MatrixEvent[]) => void and calls
onRepaired(allMapped). The component-side callback (at each
scheduleReconcile call site) routes the batch through
setSupplementalThreadEvents(threadId, [...repairedEvents]) + tick.
mergeThreadRenderEvents dedups by event id, so double-injection
with the SDK path is a no-op there.
That preserves the P3.3 render-only boundary invariant (engine has no
import of setSupplementalThreadEvents) while making BOTH render paths
converge on the same tick: SDK-populated thread.events AND
component-owned fallbackThreadEventsState.events.
Callers updated:
- threadOpenCacheFirst.ts: complete-coverage path
- threadOpenLifecycleController.ts: partial-coverage path (and
passes setSupplementalThreadEvents through to cache-first)
- mindroomSyncEngine.ts room-open: unchanged (no onRepaired)
Red-first evidence:
- reconciler.test.ts new unit asserting onRepaired receives the
fetched batch as array-of-MatrixEvent. Pre-fix: batchArg is
undefined; post-fix: length 2 with both fetched ids.
- threadOpenCacheFirst.test.ts new unit modeling the reconciler
firing onRepaired with a repaired batch, asserting
setSupplementalThreadEvents was called with (threadId, batch).
Pre-fix: 0 calls (callback was a bare tick); post-fix: 1.
- Defensive companion asserting empty batch is a no-op on the
component side.
Validation: typecheck clean; full vitest 337/337 files 2576/2576 tests
(+3 from v3); build OK; lint at exact 18-warning baseline (0 errors).
…NY-207 P5-GATE-FIX v4) Team-lead diagnosis of AC2 re-run against v3 tip 52e3749 (clean network, 0 resets): still failing at line 236. The complete-coverage cache-first reopen path has room.getThread(threadId) === null by design at injection time (SDK bootstrap skipped), so the v3 SDK-side leg (liveThread.addEvents) no-ops silently. Convergence has to come through the widened onRepaired -> setSupplementalThreadEvents leg. The AC2 spec's probe log was too muted to distinguish that shape from other failure modes. Changes (observability-first, no behavior change to the fix chain): - cacheProbe.ts: add reconcilesThreadNull counter, exposed on window.__MINDROOM_CACHE_PROBE__.snapshot(). - reconciler.ts: SDK-injection branch now has an `else if (!liveThread && allMapped.length > 0)` arm bumping the counter and emitting a `reconcile-thread-null` timeline debug log with mappedCount. The repair itself still runs against cachedPage.hydratedEvents and fires onRepaired(allMapped) so the render-fallback leg carries the change through setSupplementalThreadEvents (as wired in v3). - e2e AC2 spec: probe polling now fires a t=0 baseline immediately after reopen navigation (not just t=2s), and the comment above documents a diagnosis fork for the four possible signatures so the next docker trace is self-interpreting. - reconciler.test.ts: new red-first unit 'still delivers repairedEvents through onRepaired when room.getThread returns null — P5-GATE-FIX v4 AC2 complete-coverage reopen'. Stashing reconciler.ts and running: `expected +0 to be 1` on reconcilesThreadNull. Restore: 18/18 tests pass, incl. assertion that onRepaired fires with the full mapped batch even when getThread === undefined and reconcilesRepaired bumps (proof the hydrate path survives the SDK-thread-absent branch). Validation: - npm run typecheck: clean - npx vitest run src/app/mindroom/engine/__tests__/reconciler.test.ts: 18/18 pass - npx vitest run src/app/mindroom/threads/cacheProbe.test.ts: 5/5 pass (shape-agnostic) - Full vitest: 337/337 files, 2577/2577 tests (+1 from v3 baseline) - npm run build: OK (sw + main bundles built) - npm run lint: exact 18-warning baseline, 0 errors, zero delta Docker AC2 gate remains team-lead's to run. The next failing trace should distinguish itself between the four signatures documented in the FORK_CHANGES.md Runbook entry for v4.
…P6.1)
Introduce the D4-native prefetch settings shape in `engine/prefetchPolicy.ts`:
`PrefetchScope` literal type ('my-server' | 'all-rooms' | 'current-room-only'),
`sanitizePrefetchScope`, `sanitizePrefetchDepth` (clamp to
[ROOM_TAIL_PREFETCH_DEPTH, CURRENT_ROOM_DEEP_HISTORY_TARGET], integer, silent
fallback), and pure `resolvePrefetchConfig` that consumes a settings snapshot
and returns {scope, currentRoomDepth, roomTailDepth, threadInventoryLimit}.
Non-user-visible bounds (ROOM_TAIL_PREFETCH_DEPTH, THREAD_INVENTORY_PREFETCH_LIMIT)
stay constants.
Transitional shape: `mindroomSettings` grows `prefetchScope` and `prefetchDepth`
alongside the legacy `paginationLimit` so the surrounding tree stays green
while Commits 2-4 migrate consumers and remove the legacy field.
Tests: 17 new sanitizer + resolver assertions in prefetchPolicy.test.ts
(20 total in the file). `mindroomSettings.test.ts` unchanged: legacy field
still lives so the pre-existing hydration/sanitize/persist assertions hold.
New `MindroomPrefetchSettings` component wired through the existing
`MindroomGeneralMessageSettings` extension slot. Two SequenceCards, one
per tile:
1. "Prefetch scope" — folds `PopOut + FocusTrap + Menu` selector
(cloned from `SelectMessageLayout` in features/settings/general/
General.tsx ~762-820) offering My homeserver / All rooms / Current
room only. Writes the literal to `prefetchScope`.
2. "Current room history depth" — number `Input` matching the shape
of the deleted `MindroomMessagePreloadLimitInput` (Escape resets,
Enter/blur commits via `sanitizePrefetchDepth`, Success variant
while dirty). Bounds [ROOM_TAIL_PREFETCH_DEPTH=200,
CURRENT_ROOM_DEEP_HISTORY_TARGET=10000] enforced by the sanitizer.
Architecture guard `RoomTimeline.architecture.test.ts` flipped: the
settingsExtensionsSource must contain `MindroomPrefetchSettings` (not
`MindroomMessagePreloadLimitSetting`); settingsMenuExtensionsSource
still must NOT contain either symbol.
Tests: 6 new (renders both tiles; clamps below MIN to
ROOM_TAIL_PREFETCH_DEPTH; clamps above MAX to
CURRENT_ROOM_DEEP_HISTORY_TARGET; Escape resets; scope selection writes
the literal; Enter commits). Consumer wiring lives in Commit 3; the
legacy tile is deleted in Commit 4 (D4).
Team-lead ran instrumented docker AC2 against v4 tip c136700 and reported decisive probe data: reconcilesScheduled=3, reconcilesRepaired=2, schedulerCompleted=6, schedulerFailed=0, engineLiveWrites=0. The reconciler scheduled AND repaired twice, yet v1 kept painting — which excludes scheduling regression, fetch failure, and instance-race as the load-bearing failure. This is a documentation-only entry. No code change: the load-bearing fix (v3's widened onRepaired(repairedEvents) routed by both call sites through setSupplementalThreadEvents(threadId, [...repairedEvents])) is already in place since 52e3749. Adds to FORK_CHANGES.md a "v5 honest root-cause" entry at the top of the Runbook that: - Names v3 as the load-bearing fix (spread + Array.from in mergeThreadRenderEvents delivers a new array reference; the fallbackThreadEventsState.events state setter fires; both fallbackEvents and threadEvents memos re-derive; buildThreadEvents re-invokes hydrateCachedEvents on the merged set — that's the invalidation chain the pre-P5 tail refresh got for free). - Explains why v1 (SDK addEvents) and v2 (repair the render's cachedPage.hydratedEvents instances, not fresh clones) were real but not load-bearing on the AC2 complete-coverage cache-first shape. v1: SDK bootstrap is skipped by design on that shape, so liveThread is null and addEvents no-ops (v4's reconcilesThreadNull counter is the diagnostic). v2: in-place mutation of instances within an array whose reference hasn't changed cannot re-derive React memos that key on the array reference — the state setter never fires without v3's spread-array delivery. - Documents the memo-key audit per team-lead request: useThreadRenderState.ts:211 fallbackEvents memo deps on [fallbackThreadEventsState.events, .threadId, threadId], and useThreadRenderState.ts:218 threadEvents memo deps on [fallbackEvents, room, thread, threadEventRefreshTick, threadId, threadInitialCacheHydrated]. Reference-identity comparison on both. forceTimelineUpdate + setThreadTimelineTick alone do NOT invalidate threadEvents (they touch timeline state and a component-owned tick that this memo does not depend on). - Explains why v1 and v2 are still worth keeping despite not being load-bearing: v1 remains correct for the partial-coverage path where the SDK thread exists (SDK listeners then see the fetched event); v2's object-identity contract is a correctness requirement so the merged set that flows through buildThreadEvents → hydrateCachedEvents at re-render acts on the render-held instances rather than dead clones. - Enumerates expected outcomes and next-diagnosis forks for team-lead's next AC2 re-run against c136700: best case AC2 converges; if it still fails with the v4 signature (reconcilesRepaired ≥ 1, reconcilesThreadNull = 1) the residual issue is downstream of the fallback-array delivery (virtualized cell cache holding stale render, or mergeThreadRenderEvents picking the wrong preferred event during dedup). Team-lead's instrumented spec-polling commit (22b20ed) is preserved in the branch — it IS the observability that made this diagnosis possible. Validation: - npm run typecheck: clean - npm run lint: exact 18-warning baseline, 0 errors, zero delta - No test files touched — no vitest run required for docs-only - Full vitest suite last ran green on c136700 (2577/2577)
Migrate MindroomRoomTimeline + its controller graph off the legacy
`paginationLimit` setting and onto `prefetchDepth` (Commit 1's D4
replacement).
- MindroomRoomTimeline reads `prefetchDepth` via useSetting +
sanitizePrefetchDepth (~22 refs); safePaginationLimit/Ref renamed
to prefetchDepth/Ref through the controller graph
(roomCacheHydrationController, roomEventOpenController,
roomPaginationCommandController, roomTimelineNavigationController,
roomTimelineWindowController, threadOpenCacheController,
threadSeedPrewarmController).
- timelinePagination.ts's `paginationLimit` parameter renamed to
`windowLimit` (mechanical — the parameter is a slice length, not a
user setting; this frees the allowlist-free 6.4 guard the next
commit installs).
- Deep-history job wiring: `MindroomRoomTimeline` snapshots
`prefetchDepth` and passes it as `targetEventCount` when calling
`enqueueRoomDeepHistoryJob`. Snapshot at effect fire (not via ref)
because the scheduler dedup key does not include the depth; a
mid-focus depth change picks up on the next mount. Chosen because
the deep-history job already accepts `targetEventCount` (P4.3);
routing through `noteRoomFocused` would require plumbing a new
setter through the engine construction path without adding
behavior.
- Test shims: `RoomTimeline.test.shared.ts` and
`RoomTimelineCollapsible.test.ts` return prefetchDepth (plus
prefetchScope='my-server' so mindroomSettings hydration is stable)
from the `useSetting` mock. `RoomTimeline.navigation.test.ts` and
`RoomTimeline.cache.test.ts` read `settingsState.prefetchDepth`.
The cache test's "keeps the first visible classic room message
anchored" case updated: pre-D4 `paginationLimit: 100` is not
representable with the new sanitizer (clamps to 200); the test now
uses 200 with the resulting `{start:100,end:300}` range assertion.
- `src/app/pages/client/inbox/Notifications.tsx` intentionally NOT
touched — its `paginationLimit` is an unrelated upstream local
parameter (notification-fetch batch size), not the MindRoom setting.
Validation: `npx tsc --noEmit` clean; focused vitest
(RoomTimeline.cache 76/76, RoomTimeline.navigation 22/22,
RoomTimelineCollapsible 12/12, roomPaginationCommandController 4/4);
full mindroom suite 232 files / 2031 tests green.
…NNY-207 P5 gate closure) Five fix iterations produced a precise diagnosis but no green live run: Tuwunel recurse verified working (curl); probe signatures nondeterministic (repaired 2->0 with identical code); the final failing run's network log shows the reconciler's limit=200 fetch NEVER fired — the executor exits before its first fetch through one of three silent paths (fetch-failure swallow, zero-divergence, shouldContinue guard abort), with the evidence pointing at the guard abort during reopen mount churn. Spec re-annotated test.fail() with the full diagnosis; Deviations entry records the pending design decision (guard-abort reschedule + the token-resume thread-scope seam); scorecard AC2 row honest. Per plan rule 6.1: red with evidence over green by wallpaper.
…Y-207 P6.1/D4)
Retires the P1.6 `MindroomMessagePreloadLimitSetting` and the
`paginationLimit` field it wrote. D4 semantics: the stored value is
DROPPED, never mapped to `prefetchDepth` — the two settings have
incompatible semantics (unbounded eager-preload target vs. clamped
[200, 10000] scrollback depth with a different default), so mapping
would give users a silent behavior change on upgrade.
Deletions:
- `MindroomMessagePreloadLimitSetting.tsx` + test
- `preloadSettings.test.ts`
- From `preloadSettings.ts`: DEFAULT_PAGINATION_LIMIT,
MIN_PAGINATION_LIMIT, MAX_PAGINATION_LIMIT,
sanitizePaginationLimit, ROOM_CACHE_PERSIST_DEBOUNCE_MS (the P1.1
sweep debounce — its subject was deleted in P3.3, the constant had
no live consumers). Survivors: THREAD_BATCH_SIZE (fetch batch
size for thread /relations), ROOM_TIMELINE_INTERACTIVE_BATCH_SIZE
(interactive pagination cap), THREAD_EDIT_COMPACTION_DEBOUNCE_MS
(P1.4 edit compaction trailing debounce). File is not empty so
the survivors stay where they are (they predate D4 and are
engine-adjacent, not settings).
`mindroomSettings.ts`:
- `paginationLimit` gone from the type.
- `withMindroomSettings` destructures-omits `paginationLimit` on
every read: even if an in-memory Settings snapshot still carries
it (a plugin, a stale fixture), we never propagate forward.
`mindroomSettingsBootstrap.ts` (new leaf module — NO transitive
import of `state/settings.ts`):
- `dropLegacyMindroomSettings()` reads localStorage, strips the
`paginationLimit` key if present, writes the cleaned blob back.
Idempotent, defensive (no-op on missing/malformed/no-localStorage).
Called from `src/index.tsx` BEFORE any transitive settings
import — `mindroomSettings.ts` would defeat the "before init"
guarantee because that module IS the settings atom.
`mindroomSettings.test.ts` — rewritten (7 tests):
- drop test (stored paginationLimit → prefetchDepth default, no key on snapshot);
- garbage-scope coerce test;
- no-paginationLimit-key-after-write test;
- scrub test (legacy key removed, everything else preserved);
- scrub is a no-op when key absent;
- scrub is a no-op on missing/malformed/array blob;
- previously 3 → now 7.
6.4 guard (new arch test `prefetchSettings.architecture.test.ts`):
- written RED FIRST before removals — three tests failed against the
pre-Commit-4 tree (file existence, offender scan, D4 test titles);
- (a) both legacy files absent;
- (b) recursive scan of src/app/mindroom/ for
/paginationLimit|PreloadLimit|PAGINATION_LIMIT/, excluding an
exemption list of non-consumers (this arch test itself, the D4
drop machinery, the D4 tests, legacy-negation guards, the
preloadSettings.ts historical header). Divergence recorded: the
brief called for "zero allowlist" but the D4 drop code MUST name
the key it strips (a `paginationLimit` in the destructure-omit is
load-bearing); the exemption list is the honest reading of "no
live consumer" and every entry is justified in the guard's doc
header;
- (c) settingsExtensions has `MindroomPrefetchSettings`;
mindroomSettings has `prefetchScope` + `prefetchDepth` + import
from `../engine/prefetchPolicy`;
- (d) mindroomSettings.test.ts contains the four D4 case titles.
The `state/settings.test.ts` ownership guard (paginationLimit not in
generic settings source) stays green — the field was never in that
file.
Validation: `npx tsc --noEmit` clean; focused vitest
(prefetchSettings.architecture 5/5, mindroomSettings 7/7,
state/settings 4/4, MindroomPrefetchSettings 6/6,
RoomTimeline.architecture 96/96); full mindroom + state/settings
232 files / 2036 tests green.
… (CINNY-207 P5-GATE-FIX v4 final)
Team-lead time-boxed final iteration: make the reconciler the deterministic
owner it was designed to be. On divergence, persist the fully-mapped
prefer-live fetched batch through persistThreadEventCacheSnapshot in
addition to the v3 dual injection (SDK addEvents + widened onRepaired
supplemental sink). This closes the design seam where the pre-v4 chain
converged in memory but never taught the CACHE about the fetched events,
so the next reopen from IDB re-hit the stale window because the gap-fill
executor only writes room scope and the live-mode gates skip catch-up
sync by design.
Divergence detection continues to compare fetched events against CACHE
records (cachedIds from cachedPage.rootEvent + events raw JSON), not
SDK state — the change is that we now WRITE BACK on divergence.
Two new probe counters for docker trace analysis:
- reconcilerPersists: bumps once per repair pass that persisted via
the engine snapshot writer.
- reconcilesOnRepairedFired: bumps AFTER onRepaired returns; the gap
to reconcilesRepaired is diagnostic (guard-skipped/throwing
callback shows as N,0 pair).
Red-first unit coverage (+3 tests, 22 total):
- persists via engine path on divergence (reconcilerPersists = 1)
- SDK-vs-cache timing race: SDK holds v2 but cache holds v1, both
persist + inject fire (the timing-nondeterminism case)
- callback-fired counter bumps strictly AFTER callback returns
- D7 no-op path does NOT persist (cost guarantee)
Validation: typecheck clean, 337/337 vitest files (2581/2581 tests),
build OK, lint 18 warnings baseline zero delta. Architecture guard
still holds (persist is called from engine/reconciler.ts, not render).
AC2 spec left annotated test.fail() from the honest-red gate closure
commit d5b2d34 — team-lead's docker gate decides whether this fix
green-flips the spec or the honest-red package remains the shipping
state.
…7.1)
Docs-only commit that closes Phase 6 + Phase 7:
- `docs/mindroom-cache-strategy.md`: full rewrite. Core Model diagram
now shows the `MindroomSyncEngine` and its four components
(WriteThrough, BackfillScheduler, CacheStore, Reconciler) with the
read APIs feeding the current-room `ThreadRecord` index. The three
legacy IDB rows in the Cache Layers table collapse into one
`cacheStore/` row (unified `mindroom-cache::<sessionId>` DB schema
v3 with `events`, `meta`, `room_ledger`, `thread_summaries`; D8
wipes the six legacy DBs on first v3 open). Write Owners names the
four engine writers and adds the explicit inverse rule that render
components and per-room controllers own ZERO writes. Read Owners
rewritten around thread-open -> reconciler and hydration helpers.
Coverage Semantics gains D7 verbatim ("coverage decides what to
paint, never whether to revalidate; complete coverage = reconcile
expected to be a no-op, not skipped"). Main Flows deletes the
Eager Preload section entirely and adds a Tiered Prefetch section
(prefetchScope setting + D3 detection, depth targets per tier,
scheduler properties). Forbidden Patterns keeps the cross-room
eager preload rule with the updated justification (cross-room work
now exists but lives exclusively in the engine's scheduler under
the user's scope policy) and adds three new prohibitions: cache
writes bypassing `cacheStore`, history fetches outside the
scheduler/reconciler, and reintroducing a preload-limit-style
setting. Review Checklist gains the scheduler-dedup and D7 "still
schedules reconcile on complete coverage" questions.
- `FORK_CHANGES.md`: Phase 6 + Phase 7 runbook entry with the full
P6.1 (four commits) and P7.1 (dead-code audit + this docs commit)
detail. Records the divergence from the six-commit brief: Commit 5
and Commit 6 are combined into this single docs commit because
the dead-code audit produced zero verified-dead orphans and
forcing an empty commit would have been dishonest.
- `docs/mindroom-cache-overhaul-plan.md`: header status marker
updated (Phase 6+7 fully landed locally; P7.2 is the orchestrator's);
§5 Phase 6 / Phase 7 markers updated with commit shas; §6.4
regression guards gains the P6.1 arch guard row with the exemption
list justification; §8 Deviations gains four new entries (arch
guard exemption list vs. zero allowlist, deep-history depth wiring
via targetEventCount snapshot, D4 drop-vs-map semantics, dead-code
sweep folded into docs commit); §9 status log gains the Phase 6+7
landing entry with per-commit detail and validation results.
Validation on the branch tip: `npx tsc --noEmit` clean;
`npx vitest run` 337 files / 2593 tests green (+25 vs the Phase 5
baseline of 2568 — 17 new sanitizer/resolver tests, 6 new UI
component tests, 5 new arch guard tests, 4 new rewritten
mindroomSettings tests; the deleted `preloadSettings.test.ts` +
`MindroomMessagePreloadLimitSetting.test.ts` remove 8 tests, net
+17 new + reshuffled elsewhere lands at +25); `npm run build`
clean; `npm run lint` 18 warnings / 0 errors — matches the pre-P6
baseline exactly (zero delta).
… into cache-overhaul/13-p6p7-settings-cleanup # Conflicts: # FORK_CHANGES.md # docs/mindroom-cache-overhaul-plan.md
* fix(simple-mode): keep spaces visible * docs(runbook): record simple-mode space PR review
* fix: guard cyclic Matrix timeline links * docs: update timeline fix validation count * docs: record timeline fix PR review
* feat(diagnostics): add opt-in deep tracing * feat(diagnostics): trace thread resume work * fix(diagnostics): harden deep tracing races * fix(diagnostics): distinguish unknown response sizes * fix(diagnostics): normalize trace durations * fix(diagnostics): close failed thread load spans * fix(diagnostics): close pagination trace gaps * fix(diagnostics): parse iOS stack frames * refactor(diagnostics): remove feature-specific trace spans * refactor(diagnostics): narrow deep trace to durable signals * fix(diagnostics): preserve clean stop evidence * fix(diagnostics): retain legacy action evidence * docs: record deep trace trim validation * docs: correct trimmed diff size * fix(diagnostics): serialize trace setting changes * fix(diagnostics): retain trace control ownership
…(CINNY-130) Newly provisioned agent users like MindRoomExpert never appeared in the invite dialog's auto-suggest even though their typed Matrix ID resolved fine. The suggestion cache bootstrapped the user directory with a single-space search term, and Tuwunel matches directory queries by case-insensitive substring over MXID and display name. A space therefore only matched users whose display name contains a literal space, so space-less display names were systematically excluded from the local suggestion cache. A natural spaced query like "mindroom expert" also returned nothing server-side because no MXID or display name contains that exact substring. Bootstrap the directory cache with "@" instead, which every MXID contains, so all directory-visible users enter the cache. This is a Tuwunel-compatible visible-user bootstrap rather than a Matrix-standard match-all guarantee, and the existing limited-response fallback stays load-bearing because production already exceeds Tuwunel's 500-result clamp. For per-keystroke server searches, queries with internal whitespace now also issue a whitespace-compacted variant, with each variant's results published independently as it settles so one hung request cannot starve the other's results. Ranking is whitespace-insensitive at the shared user-directory search boundary so compact-only hits survive the final relevance pass instead of being filtered back out by the spaced input. Verified against production Tuwunel: the space bootstrap returned 27 of 500+ users while "@" returns the full visible set, confirming the hole this fixes. Stale-request and owner guards are pinned by adversarial tests that fail if the request-generation guards are removed.
…Y-128) Sending a voice message while attachments were staged in the room composer left the attachments behind: the voice went out standalone through its own pipeline and the staged files stayed on the upload board, so the natural "attach a file and talk about it" gesture produced a thread without the attachment. The voice send path now enrolls eligible staged companions into the same send session before handing off. Companions send first in board order, the voice message sends last, and everything lands under a single thread root, matching the grouping semantics the MindRoom backend uses for text-plus-attachment sends. Inside an existing thread the batch joins that thread instead of creating a new root. Eligibility is decided at gesture time against live board state: companions that are oversized, prep-failed, upload-failed, paste-marker-only, or prepared for a different encryption state than the live room stay staged and never block the voice. A companion failure mid-batch stays local to that companion; survivors and the voice still send. Ownership is explicit: the recorder-owned voice item is not cancelable through the board's bulk Remove All, terminal voice failures release the global send claim promptly even when a companion never settles, and parked voice retries stay on the standalone pipeline rather than combining with attachments staged later. Recovery after partial failure was deliberately simplified during review: failed items remain staged on the board with no thread-binding metadata, so a later typed message can never be silently redirected into a stale recovery thread. The pre-existing repo-wide plaintext-after-encryption transition gap discovered during review is tracked separately as CINNY-131.
* fix(invite): clear stale failed search results * docs: record invite review follow-up PR * fix(invite): preserve empty search state identity * docs: record invite review completion * docs: record Fable invite review
* docs(CINNY-128): plan voice send + staged attachment same-thread grouping (PLAN-B) * plan: CINNY-128 voice send flushes staged attachments into same thread * feat(room-input): expose active send session state * fix(room-input): send staged files with voice * docs: report CINNY-128 implementation * fix(room-input): enforce combined send enrollment * fix(room-input): complete combined voice send lifecycle * fix(room-input): simplify combined voice send ownership * fix(room-input): keep companion upload failures local * fix(room-input): fail fast on terminal voice errors * fix(room-input): preserve combined voice ownership * fix(room-input): remove dead companion guard * feat(composer): unify message bundle send * fix(voice): satisfy forwarded component lint * refactor(voice): keep forwardRef render focused * fix(voice): require unified retry path * fix(composer): use send-time relation context * fix(composer): close unified send retry gaps
Read the backend-owned cron_description field for recurring scheduled tasks and share display policy across compact cards and thread headers. Preserve count fallbacks for multiple or incomplete task sets and existing execute_at behavior.
Prevent End from spinning forever when the embedded Element Call iframe stops responding. - Share one end request across both call controls and enforce a four-second host fallback. - Preserve healthy Hangup and Close behavior while cleaning up pre-join endings. - Make embed disposal idempotent, fault-tolerant, and exact about Matrix listener removal. - Preserve started-call room ownership and reset joined state when the embed changes. - Keep every independently useful lifecycle fix from #189 without importing its coupled RTC membership and room-retirement subsystem. Validated with 21 focused call tests, the full 3,396-test suite, typecheck, production/PWA build, ESLint, formatting, independent exact-head review, and green PR checks.
* fix(messages): keep Matrix IDs literal in code * docs: record PR review status * fix(messages): preserve custom code link labels * docs: close code mention review
* feat: default to simple mode with expanded messages * test: prove malformed settings use defaults
* Fix expired tool approval cards * Update approval validation count * Avoid duplicate approval expiry parsing * Test live approval expiry timestamp format * Parse approval expiry timestamps deterministically * Pin approval expiry edges and drop unreachable guard
* feat: restore recent threads panel * fix: harden recent thread panel resizing * refactor: simplify recently opened threads * fix: anchor recently opened at sidebar bottom * docs: record persistent recently opened validation * docs: record current dev validation * fix: make recently opened panel resizable * test: cover touch resizing * fix: collapse recently opened by default
* feat: unify color tokens across all five themes Stage 1a of the visual refresh. All five palettes are now generated from one OKLCH ramp on a single brand hue (288deg), replacing the split where light/silver used a blue Primary and the dark family a lavender one. - Add a fork-owned lightTheme in colors.css.ts and stop importing folds' built-in one. No other module imported it. - Tint neutrals with a trace of the brand hue so greys look chosen rather than dead; midnight raises the tint, butter tints warm instead. - Move ContainerLine to ~0.03 lightness from its own Container instead of ~0.13, so borders read as edges rather than hard rules. - Make Other.Shadow translucent everywhere. The dark family used opaque black, which defeated the diffuse softShadow tokens. Butter gains its own Other block instead of inheriting the shared dark one. - Share one darkAccents object across dark, midnight and butter; only the neutral ladders and Secondary differ. - Give silver its own darkened accents, since its darker background can't hold 4.5:1 against the shared light ones. - Point --tc-link at Primary.Main per theme kind; it was a standalone blue that the new violet Primary would have clashed with. Ramps were generated and contrast-checked with chroma-js at authoring time; only static hex is committed and no color math runs at runtime. All 155 audited pairs pass WCAG AA, with Success.Main and Warning.Main on light-kind backgrounds held to the 3:1 non-text threshold as icon colors. Every Background.Container hex is unchanged, so the duplicates in index.css, themeBootstrap.ts and the pre-paint bootstrap in index.html needed no edit. No vertical metric changed, so the height-calibrated virtualizer estimator is untouched. * docs: record stage 1a screenshot verification * feat: add optical tracking and a real mono font stack * feat(design): unify motion with shared duration and easing tokens Every transition in the app picked its own duration and curve by hand. Hovering across the sidebar, the thread list, and a message ran 100ms linear, 120ms ease, 0.15s with no curve at all, and 200ms cubic-bezier(0, 0.8, 0.67, 0.97) - three speeds at three accelerations for what is the same gesture. Motion.css.ts defines five durations and four easings on :root. The steps sit close to the values already in use, so adopting them is a snap-to-grid rather than a re-timing; Fast and Slow are exactly the two most common existing values. Fifteen call sites across eight files now use them. The transition() shorthand builder lives in a separate plain module rather than in Motion.css.ts, because a .css.ts file may only export values vanilla-extract can serialize. Exporting a function from one builds fine until a regular .tsx imports it, and ThreadTagPill.tsx does. index.css gains a global prefers-reduced-motion rule, but it clamps transition-duration and scroll-behavior only. Collapsing a transition is always safe: the end state arrives immediately and nothing is lost but the travel. The usual blanket snippet also clamps animation-duration and iteration-count, which would freeze the spinner, the typing dots, and the streaming indicator on one frame - those are the only signal that something is in progress, so clamping them would remove information rather than motion. Animations opt out one at a time instead, which here means dropping the wobble from the call avatar while keeping its glow. * feat(composer): acknowledge focus with a Primary ring Typing into the composer was the one interaction with no visual acknowledgement at all: the caret appeared and the box stayed exactly as it was. :focus-within thickens the existing inset ring from B300 in SurfaceVariant.ContainerLine to B400 in Primary.Main, on the shared transition timing. Inset rather than outset so it cannot be clipped by an ancestor and cannot move anything, which matters because the composer shares a column with a virtualized timeline whose row heights come from a content-based estimator. Confirmed byte-identical getBoundingClientRect() focused and unfocused. :focus-within rather than :focus is deliberate: reaching for the emoji or attachment button keeps the composer group lit, which is the accurate description of what has focus. As a non-text indicator the ring needs 3:1 against SurfaceVariant.Container and gets 5.30:1 (silver) through 5.76:1 (dark). It also reads at least 4.49:1 against the resting ContainerLine it replaces, so the state change is unmistakable rather than a thickness difference. * feat(theme): put avatar and name colors on one OKLCH ramp The two colors that identify a person were generated by two unrelated rules and clashed. Avatar fallbacks came from eight hand-written HSL values; sender names came from power-level tag colors passed through accessibleColor, which only clamped LAB lightness. The default moderator green #1fd81f therefore stayed fully saturated in both themes - a neon name beside a muted avatar, in an app whose every other surface had just moved onto one ramp. --mx-uc-1..8 become eight evenly spaced OKLCH hues, 45 degrees apart, rotated 32 degrees off zero so none lands on the 288 degree brand hue. Preserving the original hues was tried first and rejected: 208 and 242 collapse into two near-identical blues once forced to share a lightness, and telling people apart is the entire job of these colors. Each value is checked in both directions, since colorMXID uses it as name text and as an avatar background with Surface.Container as the letter; worst case 4.56:1 light, 7.69:1 dark. accessibleColor now normalizes any tag color onto the same targets. Hue stays the caller's, since that is what distinguishes one tag from another; lightness and chroma become the theme's. Chroma is pulled in until the color is inside sRGB rather than letting .hex() clip, because clipping drags hue and lightness with it, which is the opposite of what a fixed-lightness ramp is for. All seven default tag colors pass 4.5:1 in both groups. * fix(sidebar): stop unread counts out-shouting mentions UnreadBadge rendered both states solid, so an ordinary unread count came out near-black in the light themes and near-white in the dark ones, at roughly 15:1 against the page, while a mention came out green at 4-10:1. A near-black slab outweighs a green one no matter what the green means, so the sidebar shouted loudest about the thing that mattered least - and in a busy account nearly every room has a count, which is what turned the room list into a wall of chips. Counted non-highlight badges drop to Soft with an outline; mentions keep Solid. The number still reads at 10.2:1 (dark) to 13.4:1 (light) inside the soft pill - only the slab behind it recedes. The outline is what keeps it legible as a pill on the light themes, where Secondary.Container is barely off the sidebar background; folds draws it with outline rather than border, so height stays 16px in both states. The countless form stays Solid: it is an 8px dot with no text inside it to carry the meaning, so softening it would leave nothing to see. The solid mention chip now reads 3x to 7x louder against the page than the softened count in every theme, where it was quieter in all five before. * feat(code): put syntax colors on the app's OKLCH ramp ReactPrism.css carried two unrelated palettes: an ad-hoc light one and Monokai for dark. Monokai's hot pink and acid green are a whole design language of their own, and agent output is mostly code, so the largest colored surface in the app was the one speaking a different dialect from everything around it. The light palette had its own inversion - #0f4777 made comments a saturated blue, louder than the code they annotate. Both are regenerated on the rule the --mx-uc-* colors and accessibleColor already use: hue carries the token's identity, lightness and chroma belong to the theme. Light sits at L 0.48 / C<=0.135, two steps darker than the user colors because code sits on SurfaceVariant.Container and silver's is darker than any message background; worst case 4.72:1. Dark is the usual L 0.82 / C<=0.115, worst case 6.56:1 against butter's container. operator, punctuation and comment sit off the semantic ramp because they are scaffolding rather than content, descending in weight in that order and all still clearing 4.5:1. "Quieter" flips direction between the groups, so the dark comment comes down off L 0.82 rather than sitting on it - keeping it on the ramp made the quietest token the brightest thing in the block. * fix(sidebar): give Recently Opened the app's scrollbar The Recently Opened list was a plain overflow: auto div, so it was the one scroller in the sidebar showing the platform's own scrollbar - a grey slab sitting directly below a room list that scrolls through folds' Scroll. It now uses Scroll with the same settings PageNavContent gives that room list. Scroll sets height: 100%, which only resolves inside a box the flex layout has already sized, so the sizing moves to a wrapper and the scrolling stays on the child - the same split PageNavContent uses. Overriding folds' height from our own class would have depended on stylesheet order. paddingRight goes away because Scroll reserves its own 8px gutter and the room list pads to 0 on that side for the same reason; keeping it would have stepped the two lists out of alignment. * fix(thread): stop the header label out-shouting the thread title The thread header put its emphasis on the wrong line. "Thread View" rendered at B400 - 14px, W500, full opacity - while the thread title below it rendered at T200 with priority 300: 12px, W400, 0.75 opacity. The label says the same thing on every thread the user opens; the title is the only part of that bar that identifies the conversation. Swap the two. "Thread View" becomes an eyebrow (L400, priority 300, uppercase, 0.04em tracking) matching the treatment the sidebar category headers and the SHOW MORE pill already use for chrome labels, and the title takes T300 at full opacity with W500. Measured live: eyebrow 6.53:1 dark / 6.88:1 light against the banner container, title 10.22:1 and 15.05:1. The banner grows 75px to 77px because only the subtitle line changes size - the title row is governed by the back button - and it sits outside the virtualizer, so the row estimates in threadRenderUtils.ts are untouched. The +N tag overflow chip also drops its hardcoded rgba(128,128,128,0.2) for the SurfaceVariant container tokens, so it tracks all five themes and the tag pills beside it instead of being one grey slab everywhere. * feat(theme): put the last shadows on theme tokens Stage 1a moved elevation onto config.shadow, whose Other.Shadow token is rgba(31, 30, 38, 0.13) in light, 0.16 in silver, and rgba(0, 0, 0, 0.55) in dark and midnight. Five sites still painted their own black, so they were tuned for exactly one theme: too heavy on light and butter, invisible on midnight. The two tag dropdowns and the collapsible pill take E100, the info popover takes E200. The code-block truncation fade was also pointing the wrong way, darkening downward over a surface it did not match; it now fades into SurfaceVariant.Container, which is what BaseCode paints the block with, the same shape CollapsibleGradientOverlay uses for message bodies. src/app/**/*.css.ts now has no hardcoded colors left. * fix(theme): keep soft badges visible where the palette collides The new palette makes Secondary.Container the same hex as SurfaceVariant.Container in dark, midnight and butter, and the same hex as Background.ContainerActive everywhere but silver. Any Soft Secondary badge landing on one of those surfaces loses its pill and leaves the digits floating. On dev the dark pair was #333333 against #404040, so this is new. The thread card's message count now draws its outline, like the two sibling badges that already do. The unread count stops softening on the selected row, which is already resolved by the selection highlight and so gives up nothing by staying solid. This patches the two call sites, not the collision. Moving Secondary.Container off its neighbours repaints every Soft Secondary surface in the app, and this branch has no live coverage of butter, midnight or silver to catch what that would break. FORK_CHANGES.md records the collision and the unaudited surfaces. * docs: compress the design-refresh runbook entries Eleven stage entries at six to eight dense bullets each had turned the top of the Runbook into 150 lines to scroll past before reaching anything else. The per-stage prose was mostly reasoning that the commit messages and the code comments already carry. One table row per stage, then the things that actually cost someone time if they do not know them: the Secondary.Container collision, the pinned T400/T300 tracking the virtualizer estimator depends on, why transition.ts cannot be a .css.ts, why reduced motion skips keyframes, and the duplicated Background.Container hexes.
* fix: restore scrollbar thumb contrast * docs: record scrollbar PR status * refactor: simplify scrollbar contrast fix * fix: cover standards scrollbar fallback * test: read scrollbar tracks from palette * fix: cover textarea scrollbars
* fix: preserve browser number shortcuts in composer * test: cover command number shortcuts * test: strengthen composer shortcut coverage
…203) * fix(ios): pin the app shell and portal host to the visual viewport (CINNY-132) In the iOS Add-to-Home-Screen PWA, tapping the message composer left a large gap between the composer and the keyboard, and the whole app could scroll further than it should. iOS Safari never shrinks the layout viewport for the software keyboard. Instead it pans the visual viewport down over the layout viewport to reveal the focused editor, and reports that displacement as visualViewport.offsetTop. Nothing in the codebase read offsetTop. The shell was sized to visualViewport.height but still anchored at layout y=0, so it ended exactly offsetTop pixels above the visible bottom. That interval sat below the room page but inside #root, so it painted the app background: the reported gap. The remaining pan range is what allowed the app to scroll past its own bounds. The gap height was identically visualViewport.offsetTop, and offsetTop == 0 was already the correct geometry, which is why scrolling up worked around it. The native Capacitor wrapper was unaffected because Keyboard.resize: 'native' shrinks the real WKWebView layout viewport, so visualViewport.height equals innerHeight and offsetTop is always 0. The hook now publishes --app-viewport-offset-top alongside --app-height and subscribes to visualViewport 'scroll' in addition to 'resize', since a pan changes only the offset and fires no resize event. Both #root and its sibling #portalContainer become visual-viewport followers pinned to the visible window. Positioning the portal host alone was not sufficient: the command palette and FilterBarMobileSheet sized themselves in svh/dvh, which track the layout viewport regardless of the parent box, so both now measure --app-height. The portal host is pointer-events: none with a zero-specificity :where() restore on its children, which keeps folds' own click-through tooltips intact. PopOut remains layout-viewport anchored; it is a small surface placed from a measured getBoundingClientRect(), so it degrades gracefully. Documented as a known limitation. top is used rather than transform: translateY(). A transform would make #root the containing block for every position:fixed descendant. top on a fixed element creates no such containing block. Changing #root from static to fixed also re-resolves the containing block for position:absolute descendants that have no positioned ancestor. All 50 absolute-positioned sites across 33 files were audited by tracing real render ancestry. All 50 are inert under the change: the visually hidden live regions carry all-auto insets with explicit 1px geometry, and every other site already has a positioned owner. Also removes two dead compensation paths: the window.scrollTo(0, 0) call, which cannot work because html and body are both overflow:hidden and the displacement is a visual-viewport pan, and the duplicate per-view --app-height lock on the RoomView page. The hook is now mounted once at App rather than per room. orientationchange and pageshow no longer force layout-viewport geometry, since neither event proves the keyboard is gone. Coverage locks the behavior rather than the source text. mobileKeyboardViewportGeometry.test.ts mounts the real App with the shipped stylesheet in CSSOM and drives a scroll-only pan; e2e/cinny132-keyboard-viewport.spec.ts measures the same invariant in Chromium with real getBoundingClientRect(). Each of the five load-bearing pieces (the App mount, #root position, its top, its height, and the portal host rule) was reverted in isolation and confirmed to turn both suites red. Desktop, Android web and the Capacitor wrapper are identity cases; the resulting geometry is unchanged there. Not verified on a physical iOS device: no desktop browser performs a real visual-viewport pan, so that WebKit reports the offset assumed here still needs on-device confirmation. * fix(mobile): detect keyboard viewport geometry * fix(mobile): ignore stale Safari viewport geometry
* fix: restore bottom-sheet corner radii with the folds radii token The command-palette and thread-filter mobile sheets set their top corner radius through var(--radii-400), but folds hashes its CSS variable names, so the variable never resolved and both sheets rendered with square corners. Use config.radii.R400 instead, which also keeps the sheets on the fork's modernized radius scale. The folds mocks in the three affected test files gain config.radii, and the command-palette expectation pins the resolved value. * docs: link bottom-sheet radii runbook entry to PR #200 * test: cover thread filter sheet radius token * docs: record bottom-sheet validation
* fix: separate Recently Opened rows (CINNY-133) Each entry in the Recently Opened panel rendered the room name and timestamp on the first line and the thread title on the second, with adjacent entries separated only by whitespace. The spacing was inverted relative to the grouping it needed to express. folds line boxes leave 3px of half-leading per side, so the outer column's 4px gap put 10px between the two lines inside a row while adjacent rows sat only 6px apart. Under the Gestalt proximity principle the closer pair reads as the group, so each title appeared to belong to the next row's room name rather than its own. Reordering the two lines alone does not correct this, because it leaves both distances unchanged. Three changes restore the intended grouping without altering the 42px row pitch. The title moves above the metadata line, so the brightest and largest text starts every row and the visual order matches the accessible label, which already read title-first. The outer column's gap="100" is removed and respent as a margin-top on every entry after the first, inverting the optical distances to 6px within a row and 10px between rows. The 4px is moved rather than added: the row box goes from 42px to 38px and the reclaimed space becomes the inter-row band. The inner room/timestamp Box keeps its horizontal gap="100". A single 1px ContainerLine hairline is drawn inside that band via `& + &::before`, inset to match NavItemContent's text paddings. It is absolutely positioned, so it adds no layout height, and the adjacency selector means there is no rule above the first row, none below the last, and neither margin nor rule for a single-entry list. The -2px offset is deliberately integral rather than a centered -2.5px, which keeps the rule crisp at DPR 1. Row height math, truncation behavior, metadata opacities and the SpaceBetween flex pair are all unchanged, so the timestamp keeps its own width and the room name truncates first. recentlyOpenedPanelHeight.ts is untouched. * test: cover Recently Opened row grouping
* fix: render long-text preview markdown immediately * perf: streamline preview marker placeholders * fix: normalize long-text reply preview fallback * fix: group long-text preview tool calls * fix: harden long-text preview markdown * fix: bound long-text preview edge cases * fix: preserve container fence boundaries * fix: scope preview container parsing * docs: record final preview review * refactor: simplify long-text preview rendering * fix: keep preview fallback conservative * docs: record preview closure review * fix: preserve preview math and list semantics * fix: align preview list and reply handling * fix: match preview container boundaries * fix: align preview parsing with parser * fix: honor escaped preview syntax * fix: preserve nested marker prefixes * fix: keep wrench list items literal * fix: keep wrapped wrench list markers literal * fix: preserve ordinary wrench list formatting * fix: validate nested preview tool markers * fix: match nested marker DOM semantics
* fix: persist deep trace intent across relaunches * test: cover deep trace activation failure intent * fix: keep deep trace runtime status truthful * docs: finalize deep trace runbook * refactor: clarify deep trace status copy * fix: report saved deep trace intent in exports * docs: finalize deep trace PR runbook * docs: condense deep trace runbook
A Dutch-locale Chrome on chat.mindroom.chat replaced a thread with
react-router's default error page and "NotFoundError: Failed to execute
'removeChild' on 'Node'". Chrome's translator moves React-owned text
nodes into injected <font> wrappers, so the next commit removes a node
from the wrong parent and the throw escapes to the router boundary. The
reported stack was itself translated ("at" rendered as "op"/"bij"), which
confirms translation was live on the document. Streaming MindRoom replies
commit constantly, so a translated thread crashes quickly.
Mark the document notranslate so Chrome's translator stays out of the
React tree; the in-app language picker already covers en/de/nl and the
language detector already resolves the browser locale ahead of the html
tag, so a Dutch browser still gets a Dutch UI.
Add installDomMutationGuard() as a second layer for extensions that
ignore notranslate: removeChild and insertBefore no-op (or append) on a
parent mismatch instead of throwing. It runs before the first React
commit, is idempotent, and warns once per session so genuine app-side DOM
bugs stay visible.
Author
|
Opened against the wrong remote — this targets our downstream fork, not upstream Cinny. Apologies for the noise. |
|
I have read the CLA Document and I hereby sign the CLA You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A user on a Dutch-locale Chrome hit this on a thread at
chat.mindroom.chat:The whole app was replaced by react-router's default error page ("Unexpected Application Error!").
Root cause
Chrome's page translator moves React-owned text nodes into injected
<font>wrappers. React still holds the original node and callsparent.removeChild(node)on the next commit, which throws out of the commit phase and escapes to the router error boundary.Evidence that translation was live on the document: the reported stack trace was itself translated — V8 never localizes the
atprefix inerror.stack, yet the screenshot showsop pte (...)andbij U1 (...)."Onverwachte toepassingsfout!"also does not appear insrc/app/locales/nl.json; it is react-router's hardcoded English heading, translated in place.Streaming MindRoom replies send frequent
m.replaceedits, so a translated thread re-commits constantly and crashes quickly. That matches the?threadId=URL in the report.App-side causes were ruled out: the only
removeChildinsrcisCallEmbed.ts:299, already wrapped in a guardedattempt(), anddom.ts:116appends its temporary clipboard input outside the React root.Fix
Layer one — stop the translator.
index.htmlnow setstranslate="no"on<html>and adds<meta name="google" content="notranslate">. This costs nothing in reach: the in-app language picker already covers en/de/nl, andi18next-browser-languagedetectoralready resolvesnavigatorahead ofhtmlTag, so a Dutch browser gets a Dutch UI without the browser translator.Layer two — survive anything else.
installDomMutationGuard()patchesNode.prototype.removeChild/insertBeforeso a wrong-parent call no-ops (or appends) instead of throwing. This covers extensions that ignorenotranslate. It runs before the first React commit, is idempotent, returns an uninstall function for tests, and logs oneconsole.warnper session so genuine app-side DOM bugs stay visible.Changes
index.html—translate="no"+notranslatemetasrc/app/utils/domMutationGuard.ts— new guardsrc/index.tsx— install before the first commitsrc/app/utils/domMutationGuard.test.tsx— regression coverageFORK_CHANGES.md— Runbook entryValidation
domMutationGuard.test.tsx(7 tests): the unguarded crash reproduces deterministically, the guarded path recovers and keeps rendering, warn-once, reparented-anchor append, well-formed calls untouched, idempotence, uninstall restorationnpm run typecheckcleannpm run buildclean; both attributes verified present indist/index.htmlnpm run lint0 errors, 17 warnings (existing baseline)npx prettier --checkon touched files, andgit diff --check, both cleannpm run test: 3497 passed, 1 failed —src/app/styles/scrollbarTheme.test.ts, which reproduces on cleandevin a throwaway worktree. It asserts on hashed selectors innode_modules/folds/dist/style.cssand reads no file this PR touches.Deploy note
Cached clients keep crashing until they pick up the new
index.html, so this needs the Cinny deploy to/var/www/cinny/distto take effect.