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>