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>