The committed gate certified only the CV-realization half of H1: it
terminated at the ~600px fetchOlder sentinel band, so the pagination
half — cold rows paged in ABOVE the viewport by scrollback — was proven
only by Quinn's throwaway probe, not by the gate as it runs (the
coverage gap Quinn [9] and Dawn [10] flagged, land-now/fast-follow
ratified by Eva [11]).
Root cause: when a prepend lands, scrollTop jumps UP (~100 older rows of
height added above), so `appliedTop = before - after` goes negative and
the loop's top-of-history break fired at the sentinel — before scoring
any paged-in row. On the RED baseline this break was deterministic; on
GREEN it was flaky (the prepend was scored only when the re-anchor
happened to leave the tracked row in the safe band).
Fix: distinguish the two causes of a non-decreasing scrollTop by
mounted-count. A prepend grows it; the true top does not. On a prepend
re-anchor, skip scoring that step (its motion is the multi-thousand-px
jump, not a fixed STEP notch — scoring it would poison the metric) and
CONTINUE; the next iteration re-baselines a fresh safe-band row in the
paged-in window and scoring resumes across the prepend. Assert
`prependObserved` so the gate exercises BOTH jitter sources every run.
Verified on the T3 integration tip 1fa4551a (GREEN) and the T1.1 base
32e41e78 (RED), both rebuilt:
- GREEN: prepend scored (50->200, walks all crossings), gate 0.00/0.00
PASS, 77-78 scored steps, deterministic across 4 runs.
- RED: prepend scored (50->150), coverage assert PASSES, gate still
FAILS on jitter (peak 41px > 2.0) — reds for the right reason.
The dual-sided RED/GREEN contract and the anti-cheat floors are
preserved; the change is scoped to the gate file only.
Note: gate-run R is window-dependent (Eva/Max [8]); scoring the deeper
paged-in population raises the non-gating R diagnostic from 75 to ~85.
This is NOT a cross-lane leak — the T2 writer's reserve is untouched;
R moved only because the sample window grew, exactly as the
window-sensitivity caveat predicts.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Two of the deferred scroll-jitter seams, on top of the integrated fix train
(T1.1 gate -> T2 writer -> T3 estimator @ 1fa4551a):
- columnWidthPx: measure the timeline row column once (useElementWidth) and
thread it into timelineRowReserveStyle so row-height reserves use the real
wrap width instead of the 64-char fallback. Pre-measure zero passes undefined
to preserve the estimator's own fallback rather than tripping its min-chars
floor. On jitter-corpus this shrinks estimate-error R from 75 -> 55 peak
(better reserves = smaller realized-vs-reserved delta) while the motion gate
stays GREEN 0.00/0.00 -- R moves only in the estimator lane, the gate only in
the writer lane, cross-lane invariant intact.
- Wren #4 anchor-contract guard: a companion perf test that reads the PRODUCTION
computed overflow-anchor on the real conversation scroller (no test override)
and asserts it resolves to none. The jitter gate forces the property for its
measurement; this proves the shipped stylesheet actually ships it, so a
regression that hands correction back to Chromium's native anchoring (masking
a WKWebView-only on-device break) fails loudly.
The onRealizedHeight seam is intentionally NOT wired: T4 ground truth
(RESEARCH/GUI_SCROLL_RECLAIM_T4.md) shows the upscroll path is append-only with
no unmount, so it needs no JS height cache; a per-event-id cache is only the
deferred, narrowly-scoped T4b for the head-refetch case. Adding an unused
callback now would be dead abstraction.
Validation:
- pnpm --dir desktop typecheck: clean
- biome check on both changed files: clean
- rowHeightEstimate.test.mjs 16/16, useAnchoredScroll.test.mjs 2/2
- perf: upscroll-jitter GREEN 0.00/0.00 (R 55), anchor-contract PASS
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Scrolling UP a fully-loaded channel was subtly jumpy while scrolling down
was smooth. Root cause (Eva's H1): rows above the opening viewport have
never painted, so they sit at their `contain-intrinsic-size` reserve under
`content-visibility: auto`. Scrolling up, each row realizes at its true
height the instant it enters the realization band; the delta (true -
reserve) shifts content above the reading row. On shipped WKWebView there
is no `overflow-anchor` to correct it, so every realization lands raw on
the reading position — one lurch per row.
The fix makes the conversation scroller own its reading position through a
single same-frame compensation writer:
- Snapshot the reading anchor (first fully-visible row + its top offset) on
every scroll event, before the ResizeObserver fires. The RO runs after
layout and before paint, so the "before" position can only come from the
last scroll event — never from inside the RO callback.
- Observe each `.timeline-row-cv` row (CV realization is a resize of the
row's own box and does not surface as an ancestor resize in Blink), seeded
at observe time with the browser's current layout height read from
`contain-intrinsic-block-size` — the reserve for an unpainted row. Seeding
from the reserve is what turns the first realization into a measurable
`realized - reserve` delta instead of an unmeasurable first sighting.
- On a real height change, re-pin the reading anchor to its saved offset
with a same-frame `scrollBy(drift)`. A single measured correction: the
layout engine already summed the above-anchor deltas into the row's top,
and rows resizing below the anchor don't move it, so they're excluded for
free. Re-baseline after the correction.
- Force `overflow-anchor: none` on the scroller so the writer is the single
scroll authority on every engine — Chromium's native anchoring can't
double-correct behind the writer's back, and WKWebView has none anyway.
This also aligns CI (Chromium) with macOS (WKWebView), which ran different
scroll contracts — how the jitter shipped unseen.
Verified against the committed T1.1 gate (upscroll-jitter.perf.ts) on
jitter-corpus cold-load, sync fixed-step actuation, overflow-anchor forced
off: RED without this writer (peak-dev 46 / rms 23), GREEN with it (peak-dev
0.00 / rms 0.00, reproducible), and the non-gating wheel felt-mode pass also
0.00/0.00. The non-gating estimate-error R diagnostic stays at 75 (T3's lane;
this writer compensates motion, it does not shrink the reserve error).
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Dawn found the RED gate from 33e73511 was mathematically invariant to the
fix: its residual subtracted the REALIZED scrollTop delta, which includes
the fix-writer's compensating scrollBy, so the writer cancelled out and the
gate scored raw estimate error R (only T3 can move it), not motion
smoothness. Quinn and Eva re-derived and confirmed. Co-visible and any
row-vs-row differential share the disease: compensation is a uniform global
offset, so it cancels out of any quantity not referenced to the input.
Reframe (converged with Dawn/Quinn/Eva; ratified event 63f5e6e1):
- Metric is now per-notch MOTION CONSISTENCY: the reading row's viewport
motion (rowMove = after.top - before.top; NO scrollTop reference) scored
as deviation from the run median. This references the row's own motion
ACROSS notches (temporal self-reference), never a neighbour (spatial) or
scrollTop, so the writer's scrollBy survives into the metric and is SEEN.
- Actuation is a fixed synchronous step (scrollTop -= STEP) so the input
delta is constant by construction, bypassing Blink's wheel-scaling (a
wheel notch applies 218/220/222...; median-of-run would misread that
per-notch scaling as jitter). Dawn independently confirmed even the
actuated scrollTop delta is post-writer contaminated (48-75px spread
under sync) — rowMove is the only clean measurable.
- Anti-cheat floor: mean rowMove must be > STEP*0.75 so a frozen or
half-applying scroller (near-zero variance, would false-green) is caught.
- Old scrollTop-referenced residual kept as a printed NON-GATING diagnostic
(= estimate error R = T3's estimator acceptance number).
- Second non-gating diagnostic: one wheel-actuated pass of the same metric
(Tyler's real input is a wheel), on the record every run.
- RED-at-tip is a HARD gate with a VOID comment: median-of-run is only
valid while the corpus produces varying realization, and the tip run
being RED IS the proof of that dispersion.
RED at tip: peak-dev 41px / rms 24px (gate <=2 / <=0.6). GREEN target is
Dawn's real T2 writer: peak-dev 0.00 / rms 0.00, reproducible over 3 sync
runs. A synthetic per-notch-varying oracle drives 74/77 notches to exactly
STEP; the real writer re-pins in the same ResizeObserver cycle and closes
the synthetic-only outliers.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Tyler's report: scrolling UP a fully-loaded channel is subtly jumpy;
DOWN is smooth. Root cause (Eva H1 + L-C): rows above the opening
viewport have never painted, so under content-visibility:auto they sit
at estimateRowHeight()'s contain-intrinsic-size reserve (60px,
utilities.css:17). Scrolling up, each realizes at its true height; the
reserve-vs-true delta shifts the reading position. Shipped WKWebView has
no overflow-anchor, so it corrects nothing — every delta lands raw.
This adds a deterministic gate that is RED at tip 77bd0e70 and that the
converged owned-compensation fix (overflow-anchor:none + same-frame
scrollBy(realized-reserved)) turns green:
- e2eBridge: a dedicated `jitter-corpus` mock channel (400 structurally
heterogeneous rows — headings/lists/blockquotes/code fences/long prose
whose true height estimateRowHeight is known to miss), its own channel
so the uniform deep-history seed the load-older specs use is untouched.
- upscroll-jitter.perf.ts: forces overflow-anchor:none on the scroller
(mirrors shipped WKWebView; logs CSS.supports per engine), then wheels
up in notches tracking a row held INSIDE the viewport (SAFE_MARGIN band
from both edges, re-picked each step, scored only if the same id stayed
in-band before+after). Residual = row's visual delta-top minus the
scroll delta: 0 == smooth. Gate is peak <=2px, rms <=0.6px.
Validity: within a single cold run the already-realized rows read
residual 0.0 exactly while realizing rows drift -27..-75px — an artifact
would drift on both. Negative sign shows the estimator over-reserves.
At tip: peak 75px, rms 40px over 23 steps → RED. A correct fill of
scrollTop absorbs the drift into the scroll delta, driving residual->0.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding [L1] commands/** — several Tauri commands still performed filesystem, process discovery, sqlite/local-storage reads, zip/json parsing, managed-agent store work, and workspace symlink updates synchronously on the command thread. Convert the affected commands to async command handlers with explicit spawn_blocking around the blocking sections so the UI thread is not responsible for those operations.
Reacquire AppState from the owned AppHandle inside blocking closures instead of moving borrowed State<'_, AppState> or non-Send guards across await points. Keep existing store mutex serialization inside the blocking closures and preserve command-specific ordering: identity import persists before swapping in-memory keys, workspace apply validates before mutation and persists the effective repos dir before symlink updates, and repos-dir-error emissions still use the AppHandle.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
L6 finding: WorkflowsView's allWorkflowsQuery issued one get_channel_workflows
relay POST per member channel (Promise.all fanout) — N round-trips scaling with
channel count on the Workflows overview.
A nostr #h filter matches ANY listed value, so one query with all channel ids
returns the identical set. Added get_channels_workflows(channel_ids) which
queries once; each WorkflowWire already carries its own channel_id (from the
event h tag), so WorkflowsView groups results client-side via a channelId->name
map. Neither the per-channel nor the batched command sets a limit, so batching
does not change result completeness (review-bar #3: no shared-limit truncation).
e2eBridge gains a matching get_channels_workflows mock so overview e2e still
resolves. Single-channel get_channel_workflows stays for useChannelWorkflowsQuery.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
L6 finding: usePersonasQuery, useTeamsQuery, and useManagedAgentsQuery's
idle branch each ran a 30s refetchInterval that duplicated an existing
event-driven refresh path. Inbound relay changes to PERSONA/TEAM/MANAGED_AGENT
records emit agents-data-changed (personas/mod.rs), which useAgentsDataRefresh
coalesces into a query invalidate (200ms window). The 30s poll was
belt-and-suspenders disk-read IPC on top of correct event invalidation.
Removed the interval from personas and teams entirely. For managed agents,
kept the 5s branch for locally-running agents (process state can change with
no relay event, so that poll is their only liveness signal) and dropped the
idle 30s fallback (control-plane changes have the event path; the 5s branch
already captures the running->stopped transition on its last poll, and user
actions invalidate directly).
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
L6 finding: useRelayAgentsQuery ran a 30s refetchInterval backed by an
unfiltered relay query for ALL kind:10100 agent profiles, mounted on ~13
always-live surfaces (channel screen, members bar, mentions, sidebar,
profile popovers). It re-pulled the full profile set app-wide every 30s.
Relay agent profiles are near-static; 30s is far too aggressive. This poll
is also the ONLY refresh path for kind:10100 (the agents-data-changed event
fires only for local PERSONA/TEAM/MANAGED_AGENT reconcile, never kind:10100),
so it cannot be dropped. Relaxing to 5min drops relay traffic ~10x, and
refetchIntervalInBackground:false (matches channels/hooks.ts house style)
pauses it while the window is unfocused. No semantics change: the set of
relay agents still refreshes, just less often.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding L3-2: auto-heal and manual reconnect used broad queryClient.invalidateQueries(), refetching unrelated Tauri/disk/local queries after relay recovery.
Route both reconnect paths through a shared relay-dependent query predicate. The taxonomy includes relay-backed messages/channels/profiles/social/workflows/projects state and excludes local-only agent/runtime/workspace-icon/repo queries.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding L3-3: fetchChunkedAuxEvents awaited each auxiliary-event chunk sequentially, delaying visible reaction/edit/deletion backfills on large history windows.
Run aux chunks through a small shared concurrency helper so relay history requests overlap without unbounded fanout. The helper preserves chunk result order and the existing fail-fast semantics.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding L3-1: reconnect replay previously awaited every live subscription REQ and paged history replay serially, making reconnect latency grow with active subscription count.
Start live REQs together, then replay paged channel catch-up with a bounded cap while preserving ordering inside each subscription's page loop.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L2 huddle device enumeration used a sync command on provider mount/devicechange, and start/join performed sequential membership fetches plus synchronous STT/TTS ONNX construction on async workers.
Make audio output enumeration an async command backed by spawn_blocking, fetch bot/all memberships in parallel during post-connect setup, and construct STT/TTS pipelines on the blocking pool while preserving starting sentinels, stale-session checks, and final phase checks.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L2 event_sync boot reconcile did best-effort JSON reads, SQLite retention checks/writes, and event signing synchronously during Tauri setup after identity resolution.
Snapshot the resolved owner keys, then spawn the reconcile after setup-critical boot work begins. The reconcile runs on spawn_blocking so no filesystem, SQLite, or signing work occupies the GUI setup path or an async worker. Secret-store identity resolution remains synchronous and out of scope by design.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L2 archive read/save-subscription/archive_events sqlite work could block the GUI IPC path or async workers under busy_timeout contention.
Move archive planning, commits, save-subscription merge/remove/list, and paginated reads through spawn_blocking with each rusqlite connection scoped inside the blocking closure. Relay query awaits remain between DB phases with no connection or transaction in scope.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding [L4] messageProfiles structural-share (Tier 1) — red/green evidence.
`profileLookupsEqual` cases: reference identity, distinct-but-value-equal,
key-count / key-set mismatch, each of the 5 summary fields breaking equality,
empty lookups. Plus a render-count-discipline pair that replays the exact
ChannelScreen ref idiom: value-equal re-derives hold the same reference (the
memo skips — no timeline re-render on typing churn) and a real profile change
swaps it (the memo fires), then re-stabilises around the new value.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding [L4] MessageActionBar (Tier 3): the action bar re-rendered with every
row render even when its props were unchanged. Wrap it in `React.memo`. The
memo was dead on arrival, though — the row passed an inline `onRemindLater`
arrow, a fresh function identity every render that defeated the shallow prop
compare. Extract it to a `handleRemindLater` useCallback (deps
[channelId, openReminder]); all other props are already stable (openReminder
and handleReactionSelect are useCallbacks, setBadgeBurstEmoji is a state
setter), so the memo now actually skips.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding [L4] MessageRow channelNames (Tier 3): every row ran
`channels.filter(non-dm).map(name)` in its own useMemo, so the identical
derivation was recomputed once per mounted row and re-ran whenever the channel
list changed. Compute it once in `ChannelNavigationProvider` as
`nonDmChannelNames` (memoised on `[channels]`) and have rows read it directly.
Additive to the context value — `channels` is unchanged, so other consumers are
unaffected.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding [L4] MessageRow resolvedAgentPubkeys (Tier 2): every mounted row
re-scanned the full `profiles` lookup to rebuild the agent-pubkey Set, so the
same O(profiles) work ran once per row and re-ran on every profile-lookup
change. ChannelScreen already computes this exact Set once (now from the
stabilised lookup) and passes it down as `agentPubkeys`, already normalised.
Consume the passed Set directly, falling back to a module-level stable empty
Set so rows without one keep a constant reference (a fresh `new Set()` per
render would defeat the row memo). Widen `getConfigNudgeAuthorPubkey`'s
parameter to `ReadonlySet<string>` — it only reads via `.has()`, and this
matches how the `agentPubkeys` prop is already typed upstream.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding [L4] ChannelScreen.tsx messageProfiles (Tier 1): the users-batch
query re-keys on the full sorted pubkey set, so typing churn (a transient
typing-only pubkey entering/leaving the set) produces a fresh lookup object
identity even when no profile value changed. That new reference fails
MessageRow's `prev.profiles === next.profiles` memo check and re-renders the
entire timeline on every keystroke-adjacent typing event.
Add `profileLookupsEqual(a, b)` — a by-value deep-equal over the 5 scalar
summary fields (displayName/avatarUrl/nip05Handle/ownerPubkey/isAgent) — and
use a useRef stabiliser at the ChannelScreen boundary to return the previous
reference when the re-derived lookup is value-equal. Consumers read profiles
by pubkey value only and never treat identity as a change signal, so returning
the stale-but-value-identical reference is safe (traced c6237ef8). The
stabilisation does O(profiles) equality work once at the boundary, replacing
O(rows x profiles) of downstream per-row re-render.
Also derive `agentPubkeys` from the stabilised lookup (was raw query data) so
that Set only churns on a real profile change, demoting the downstream row
scans that keyed off it.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L7 pulse fixed-interval refetches.\n\nGate Pulse timeline/reaction refetch intervals on document visibility so the visible tab stays fresh while hidden windows stop issuing periodic social API requests. Initial query enablement and invalidation behavior are unchanged.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding: L7 search dialog-open kind:10100 pull.\n\nKeep message/user search behavior the same, but defer managed-agent and relay-agent queries until the debounced query reaches the existing minimum search length. Opening and closing the dialog without typing no longer triggers agent discovery/network work.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Finding [L5] AppShell.tsx:161-164 — 4 archive/ingestion hooks mounted
unconditionally at first render competed with first paint. Gate the two
archive *seed* hooks (useObserverArchiveSeed, useAgentMetricArchiveSeed)
behind startupReady by passing deferredPubkey instead of the eager
identityQuery pubkey, matching the existing presence/user-status
deferral pattern.
useArchiveSync (the eager live-save path) stays unchanged — it owns live
event ingestion and must not be deferred. Only the one-shot first-run
seeds (mergeSaveSubscriptionKinds) move off the boot critical path.
The seeds' explicit-choice guard is untouched: each hook already
early-returns on `if (!pubkey) return`, so a deferred (undefined) pubkey
is a no-op until startup completes, then the seed fires exactly once with
identical semantics. Confirmed by useObserverArchiveSeed/
useAgentMetricArchiveSeed test suites (18/18), incl.
test_undefined_pubkey_does_nothing and
test_explicit_choice_set_does_not_reseed.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>