perf(desktop): GUI performance sweep — async offload, poll reduction, render stabilization (#1641)

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-08 14:58:27 -04:00
committed by GitHub
co-authored by npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Tyler Longwell npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
parent 11608ef675
commit 2cc0eb5396
44 changed files with 1378 additions and 575 deletions
+9 -3
View File
@@ -160,10 +160,16 @@ export function AppShell() {
// guard here would drop managed-agent coverage during startup.
useAgentObserverIngestion();
useArchiveSync();
useObserverArchiveSeed(identityQuery.data?.pubkey);
useAgentMetricArchiveSeed(identityQuery.data?.pubkey);
const profileQuery = useProfileQuery();
// Defer the archive *seeds* until startup is idle: they're first-run catch-up
// config (a one-shot mergeSaveSubscriptionKinds), not live-ingest — that's
// useArchiveSync's job, which stays eager above. Passing deferredPubkey makes
// each seed hook wait on its own `if (!pubkey) return` guard until the shell
// is interactive, so their IPC + sqlite archive open doesn't compete with
// first paint. The explicit-choice guard inside each hook is unchanged.
const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
useObserverArchiveSeed(deferredPubkey);
useAgentMetricArchiveSeed(deferredPubkey);
const profileQuery = useProfileQuery();
useRelayAutoHeal();
usePresenceSubscription();
useUserStatusSubscription();
+23 -7
View File
@@ -176,7 +176,10 @@ export function usePersonasQuery() {
queryKey: personasQueryKey,
queryFn: listPersonas,
staleTime: 30_000,
refetchInterval: 30_000,
// No refetchInterval: inbound relay changes to personas emit
// `agents-data-changed`, which `useAgentsDataRefresh` coalesces into an
// invalidate (200ms window). The 30s poll was belt-and-suspenders on top of
// that event path — redundant disk-read IPC.
});
}
@@ -209,7 +212,16 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) {
queryKey: relayAgentsQueryKey,
queryFn: listRelayAgents,
staleTime: 30_000,
refetchInterval: 30_000,
// Relay agent profiles (kind:10100) are near-static and the backing
// `list_relay_agents` command is an unfiltered relay query for the whole
// profile set — mounted on ~13 always-live surfaces (channel screen,
// members bar, mentions, sidebar, profile popovers), so a tight interval
// re-pulls the full set app-wide. This poll is also the ONLY refresh path:
// the `agents-data-changed` event fires only for local persona/team/managed
// reconcile (kinds PERSONA/TEAM/MANAGED_AGENT), never for kind:10100. So we
// keep polling but at a relaxed cadence and pause it while backgrounded.
refetchInterval: 5 * 60_000,
refetchIntervalInBackground: false,
enabled: options?.enabled,
});
}
@@ -222,12 +234,14 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) {
staleTime: 5_000,
refetchInterval: (query) => {
const agents = query.state.data as ManagedAgent[] | undefined;
// Only local "running" agents need fast polling (process state can
// change). "deployed" is static control-plane state — presence polling
// handles the live signal for remote agents separately.
// Only local "running" agents need polling: process state can change
// with no relay event to signal it, so this poll is the only liveness
// path for them. When nothing is running there IS an event path —
// `agents-data-changed` (control-plane changes) — so the idle branch
// drops its poll entirely rather than falling back to 30s.
return agents?.some((agent) => agent.status === "running")
? 5_000
: 30_000;
: false;
},
});
}
@@ -680,7 +694,9 @@ export function useTeamsQuery() {
queryKey: teamsQueryKey,
queryFn: listTeams,
staleTime: 30_000,
refetchInterval: 30_000,
// No refetchInterval: inbound relay team changes emit `agents-data-changed`
// (handled by useAgentsDataRefresh). Same redundant-poll removal as
// usePersonasQuery.
});
}
@@ -56,7 +56,10 @@ import { useThreadReplies } from "@/features/messages/useThreadReplies";
import { useChannelTyping } from "@/features/messages/useChannelTyping";
import type { TimelineMessage } from "@/features/messages/types";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import {
mergeCurrentProfileIntoLookup,
profileLookupsEqual,
} from "@/features/profile/lib/identity";
import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types";
import { useChannelFind } from "@/features/search/useChannelFind";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
@@ -324,17 +327,6 @@ export function ChannelScreen({
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
enabled: messageProfilePubkeys.length > 0,
});
const agentPubkeys = React.useMemo(() => {
const pubkeys = new Set(knownAgentPubkeys);
for (const [pubkey, profile] of Object.entries(
messageProfilesQuery.data?.profiles ?? {},
)) {
if (profile.isAgent) {
pubkeys.add(normalizePubkey(pubkey));
}
}
return pubkeys;
}, [knownAgentPubkeys, messageProfilesQuery.data]);
const agentPubkeysPending =
activeChannel?.channelType === "dm" &&
(channelMembersQuery.isPending ||
@@ -360,7 +352,7 @@ export function ChannelScreen({
// Observer ingestion (frame decryption + derived active-turn liveness) is
// owner-global — mounted once in AppShell via useAgentObserverIngestion —
// so this screen no longer mounts its own observer/turns bridges.
const messageProfiles = React.useMemo(() => {
const messageProfilesRaw = React.useMemo(() => {
const base =
mergeCurrentProfileIntoLookup(
messageProfilesQuery.data?.profiles,
@@ -379,6 +371,28 @@ export function ChannelScreen({
messageProfilesQuery.data?.profiles,
relayAgents,
]);
// Stabilise the merged lookup's reference across renders when no profile
// value changed. `messageProfilesRaw` gets a fresh identity whenever the
// `users-batch` query re-keys — which typing churn triggers constantly — and
// that identity flows to MessageRow's `prev.profiles === next.profiles` memo
// check, so an unstable reference re-renders the whole timeline per keystroke.
const messageProfilesRef = React.useRef(messageProfilesRaw);
if (!profileLookupsEqual(messageProfilesRef.current, messageProfilesRaw)) {
messageProfilesRef.current = messageProfilesRaw;
}
const messageProfiles = messageProfilesRef.current;
// Derived from the stabilised lookup so this Set only churns when a profile
// value actually changed — MessageRow compares `agentPubkeys` by reference,
// and each row previously re-derived this same scan locally (removed).
const agentPubkeys = React.useMemo(() => {
const pubkeys = new Set(knownAgentPubkeys);
for (const [pubkey, profile] of Object.entries(messageProfiles)) {
if (profile.isAgent) {
pubkeys.add(normalizePubkey(pubkey));
}
}
return pubkeys;
}, [knownAgentPubkeys, messageProfiles]);
const personasQuery = usePersonasQuery();
const { personaLookup, respondToLookup } = React.useMemo(() => {
const agents = managedAgentsQuery.data ?? [];
@@ -22,6 +22,7 @@ type HuddleJoinInfo = {
type VoiceInputMode = "push_to_talk" | "voice_activity";
const MIC_ANALYSER_UPDATE_INTERVAL_MS = 33;
const PIPELINE_HOTSTART_INTERVAL_MS = 15_000;
const MIC_INITIAL_NOISE_FLOOR = 0.01;
const MIC_VOICE_GATE_ON_RMS = 0.018;
const MIC_VOICE_GATE_OFF_RMS = 0.012;
@@ -522,7 +523,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
invoke("check_pipeline_hotstart").catch(() => {
/* best-effort */
});
}, 5_000);
}, PIPELINE_HOTSTART_INTERVAL_MS);
return () => window.clearInterval(id);
}, [ephemeralChannelId]);
@@ -59,6 +59,8 @@ type HuddleBarProps = {
};
const HUDDLE_DRAWER_EXIT_MS = 260;
const HUDDLE_STATE_FALLBACK_INTERVAL_MS = 30_000;
const HUDDLE_MODEL_STATUS_INTERVAL_MS = 10_000;
const HUDDLE_REACTION_NAME_MAX = 48;
function isVisibleHuddleState(state: HuddleState | null) {
@@ -212,7 +214,7 @@ export function HuddleBar({
}
setState(nextState);
}, []);
// Huddle state: event-driven + 10s fallback poll.
// Huddle state: event-driven + slow fallback poll.
React.useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | null = null;
@@ -246,8 +248,12 @@ export function HuddleBar({
else unlisten = fn;
});
// Fallback: 10s poll in case events are missed
const id = window.setInterval(() => void fetchState(), 10_000);
// Fallback in case events are missed; keep it slow so normal huddle use is
// event-driven and does not keep a sync IPC command warm on the main thread.
const id = window.setInterval(
() => void fetchState(),
HUDDLE_STATE_FALLBACK_INTERVAL_MS,
);
return () => {
cancelled = true;
@@ -293,7 +299,10 @@ export function HuddleBar({
}
void pollModels();
const id = window.setInterval(() => void pollModels(), 3_000);
const id = window.setInterval(
() => void pollModels(),
HUDDLE_MODEL_STATUS_INTERVAL_MS,
);
return () => {
cancelled = true;
@@ -3,6 +3,8 @@ import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
const AGENT_PUBKEY_REFRESH_INTERVAL_MS = 30_000;
/**
* Subscribe to agent TTS messages on the ephemeral huddle channel.
* Pipes agent kind:9 messages to `speak_agent_message` on the Rust backend.
@@ -51,7 +53,7 @@ export function useTtsSubscription(
void loadAgentPubkeys();
const agentRefreshId = window.setInterval(() => {
void loadAgentPubkeys();
}, 10_000);
}, AGENT_PUBKEY_REFRESH_INTERVAL_MS);
// ── Live-only subscription ───────────────────────────────────────────
// subscribeToChannelLive uses `since: now` — the relay never sends
@@ -365,7 +365,7 @@ function isCustomEmojiShortcode(emoji: string) {
return emoji.startsWith(":") && emoji.endsWith(":");
}
export function MessageActionBar({
export const MessageActionBar = React.memo(function MessageActionBar({
channelId,
message,
onDelete,
@@ -588,4 +588,6 @@ export function MessageActionBar({
</div>
</div>
);
}
});
MessageActionBar.displayName = "MessageActionBar";
+22 -24
View File
@@ -43,6 +43,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const DiffMessage = React.lazy(() => import("./DiffMessage"));
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
/** Stable empty fallback so rows without an agent-pubkey set keep a constant
* reference (a fresh `new Set()` per render would defeat downstream memos). */
const EMPTY_AGENT_PUBKEYS: ReadonlySet<string> = new Set();
export type ThreadDepthGuideAction = {
active?: boolean;
depth: number;
@@ -149,6 +153,17 @@ export const MessageRow = React.memo(
} = useReactionHandler(message, onToggleReaction);
const { openReminder, activeReminderEventIds } = useRemindLater();
const hasActiveReminder = activeReminderEventIds.has(message.id);
const handleRemindLater = React.useCallback(
(msg: TimelineMessage) => {
openReminder({
eventId: msg.id,
channelId: channelId ?? "",
preview: msg.body.slice(0, 100),
authorPubkey: msg.pubkey ?? "",
});
},
[channelId, openReminder],
);
const mentionNames = React.useMemo(
() => resolveMentionNames(message.tags, profiles),
[profiles, message.tags],
@@ -157,17 +172,11 @@ export const MessageRow = React.memo(
() => resolveMentionPubkeysByName(message.tags, profiles),
[profiles, message.tags],
);
const resolvedAgentPubkeys = React.useMemo(() => {
const pubkeys = new Set(agentPubkeys ?? []);
for (const [pubkey, profile] of Object.entries(profiles ?? {})) {
if (profile.isAgent) {
pubkeys.add(normalizePubkey(pubkey));
}
}
return pubkeys;
}, [agentPubkeys, profiles]);
// The agent-pubkey set is computed once by the parent (ChannelScreen)
// from the same profile lookup and passed down already normalised — no
// per-row rescan of `profiles` (that duplicated the parent's work in every
// mounted row and re-ran on each profile-lookup change).
const resolvedAgentPubkeys = agentPubkeys ?? EMPTY_AGENT_PUBKEYS;
const profilePopoverRole =
message.role === "bot" ||
(message.pubkey &&
@@ -200,11 +209,7 @@ export const MessageRow = React.memo(
);
const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
const { channels } = useChannelNavigation();
const channelNames = React.useMemo(
() => channels.filter((c) => c.channelType !== "dm").map((c) => c.name),
[channels],
);
const { nonDmChannelNames: channelNames } = useChannelNavigation();
const indentRem = getThreadReplyIndentRem(message.depth);
const descendantGuideOffsetRem = connectDescendants
@@ -452,14 +457,7 @@ export const MessageRow = React.memo(
onReactionSelect={
canToggleReactions ? handleReactionSelect : undefined
}
onRemindLater={(msg) => {
openReminder({
eventId: msg.id,
channelId: channelId ?? "",
preview: msg.body.slice(0, 100),
authorPubkey: msg.pubkey ?? "",
});
}}
onRemindLater={handleRemindLater}
onReply={onReply}
onUnfollowThread={onUnfollowThread}
reactionErrorMessage={reactionErrorMessage}
@@ -18,7 +18,7 @@ import type { TimelineMessage } from "@/features/messages/types";
*/
export function getConfigNudgeAuthorPubkey(
message: Pick<TimelineMessage, "kind" | "signerPubkey">,
resolvedAgentPubkeys: Set<string>,
resolvedAgentPubkeys: ReadonlySet<string>,
): string | undefined {
if (
message.kind === KIND_STREAM_MESSAGE &&
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import { profileLookupsEqual } from "./identity.ts";
const summary = (over = {}) => ({
displayName: "Ada",
avatarUrl: "https://x/a.png",
nip05Handle: "ada@x",
ownerPubkey: null,
isAgent: false,
...over,
});
test("profileLookupsEqual: same reference is equal", () => {
const a = { p1: summary() };
assert.equal(profileLookupsEqual(a, a), true);
});
test("profileLookupsEqual: distinct objects, identical values are equal", () => {
assert.equal(profileLookupsEqual({ p1: summary() }, { p1: summary() }), true);
});
test("profileLookupsEqual: different key count is not equal", () => {
assert.equal(
profileLookupsEqual({ p1: summary() }, { p1: summary(), p2: summary() }),
false,
);
});
test("profileLookupsEqual: same count, different keys is not equal", () => {
assert.equal(
profileLookupsEqual({ p1: summary() }, { p2: summary() }),
false,
);
});
test("profileLookupsEqual: a changed field is not equal", () => {
for (const field of [
"displayName",
"avatarUrl",
"nip05Handle",
"ownerPubkey",
"isAgent",
]) {
assert.equal(
profileLookupsEqual(
{ p1: summary() },
{ p1: summary({ [field]: field === "isAgent" ? true : "changed" }) },
),
false,
`field ${field} should break equality`,
);
}
});
test("profileLookupsEqual: two empty lookups are equal", () => {
assert.equal(profileLookupsEqual({}, {}), true);
});
// Render-count proof for the Tier-1 typing-storm fix (#1533 discipline).
// MessageRow re-renders iff `prev.profiles === next.profiles` fails, so the
// stabiliser's job is: hold the reference across value-equal re-derives (the
// per-keystroke churn) and release it only on a real value change. This
// replays the exact ChannelScreen ref idiom against a sequence of freshly
// built lookups and asserts reference identity == render decision.
function makeStabiliser() {
let ref;
let first = true;
return (raw) => {
if (first || !profileLookupsEqual(ref, raw)) {
ref = raw;
}
first = false;
return ref;
};
}
test("stabiliser: value-equal re-derives keep the same reference (no re-render)", () => {
const stabilise = makeStabiliser();
// Each entry is a fresh object identity — exactly what a users-batch re-key
// produces on every keystroke-adjacent typing event.
const first = stabilise({ p1: summary() });
const churnA = stabilise({ p1: summary() });
const churnB = stabilise({ p1: summary() });
assert.equal(churnA, first, "value-equal churn must not swap the reference");
assert.equal(churnB, first, "repeated churn must not swap the reference");
});
test("stabiliser: a real profile change swaps the reference (re-render fires)", () => {
const stabilise = makeStabiliser();
const first = stabilise({ p1: summary() });
const changed = stabilise({ p1: summary({ displayName: "Grace" }) });
assert.notEqual(
changed,
first,
"a real value change must swap the reference",
);
// ...and then re-stabilises around the new value.
const held = stabilise({ p1: summary({ displayName: "Grace" }) });
assert.equal(held, changed, "must re-stabilise around the new value");
});
@@ -5,6 +5,50 @@ export type UserProfileLookup = Record<string, UserProfileSummary>;
export { truncatePubkey };
/**
* Deep-equal two profile lookups by value. Used to stabilise the merged
* `messageProfiles` reference at the ChannelScreen boundary: the underlying
* `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 actually 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.
* Returning the previous reference when this reports equal keeps the memo
* intact. Consumers read profiles by pubkey value only, never treating identity
* as a change signal, so returning the stale-but-value-identical reference is
* safe.
*/
export function profileLookupsEqual(
a: UserProfileLookup,
b: UserProfileLookup,
): boolean {
if (a === b) {
return true;
}
const aKeys = Object.keys(a);
if (aKeys.length !== Object.keys(b).length) {
return false;
}
for (const key of aKeys) {
const prev = a[key];
const next = b[key];
if (
next === undefined ||
prev.displayName !== next.displayName ||
prev.avatarUrl !== next.avatarUrl ||
prev.nip05Handle !== next.nip05Handle ||
prev.ownerPubkey !== next.ownerPubkey ||
prev.isAgent !== next.isAgent
) {
return false;
}
}
return true;
}
function getResolvedProfile(
pubkey: string,
profiles: UserProfileLookup | undefined,
+47 -5
View File
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import {
getGlobalNotes,
@@ -13,6 +14,37 @@ import { allPulseTimelinesQueryKey } from "@/features/profile/hooks";
import { withoutProjectComments } from "@/features/pulse/lib/projectComments";
import type { UserNote, UserNotesResponse } from "@/shared/api/socialTypes";
function isDocumentVisible() {
return typeof document === "undefined"
? true
: document.visibilityState === "visible";
}
function useDocumentVisible() {
const [visible, setVisible] = React.useState(isDocumentVisible);
React.useEffect(() => {
if (typeof document === "undefined") {
return;
}
function handleVisibilityChange() {
setVisible(isDocumentVisible());
}
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
return visible;
}
function useVisibleRefetchInterval(intervalMs: number) {
return useDocumentVisible() ? intervalMs : false;
}
// ── Query keys ──────────────────────────────────────────────────────────────
export const pulseQueryKeys = {
@@ -31,6 +63,8 @@ export const pulseQueryKeys = {
// ── Own notes ───────────────────────────────────────────────────────────────
export function useLikedNotesQuery(pubkey?: string, enabled = true) {
const refetchInterval = useVisibleRefetchInterval(30_000);
return useQuery<UserNotesResponse>({
queryKey: pulseQueryKeys.likedNotes(pubkey ?? ""),
queryFn: async () =>
@@ -39,11 +73,13 @@ export function useLikedNotesQuery(pubkey?: string, enabled = true) {
enabled: enabled && !!pubkey,
staleTime: 15_000,
gcTime: 5 * 60_000,
refetchInterval: 30_000,
refetchInterval,
});
}
export function useMyNotesQuery(pubkey?: string) {
const refetchInterval = useVisibleRefetchInterval(30_000);
return useQuery<UserNotesResponse>({
queryKey: pulseQueryKeys.myNotes(pubkey ?? ""),
queryFn: async () =>
@@ -52,13 +88,15 @@ export function useMyNotesQuery(pubkey?: string) {
enabled: !!pubkey,
staleTime: 15_000,
gcTime: 5 * 60_000,
refetchInterval: 30_000,
refetchInterval,
});
}
// ── Timeline (notes from contacts) ─────────────────────────────────────────
export function useTimelineQuery(contactPubkeys: string[], enabled: boolean) {
const refetchInterval = useVisibleRefetchInterval(30_000);
return useQuery<UserNotesResponse>({
queryKey: pulseQueryKeys.timeline(contactPubkeys),
queryFn: async () =>
@@ -66,7 +104,7 @@ export function useTimelineQuery(contactPubkeys: string[], enabled: boolean) {
enabled: enabled && contactPubkeys.length > 0,
staleTime: 15_000,
gcTime: 5 * 60_000,
refetchInterval: 30_000,
refetchInterval,
});
}
@@ -79,6 +117,8 @@ export function usePulseReactionsQuery(
noteIds: string[],
currentPubkey?: string,
) {
const refetchInterval = useVisibleRefetchInterval(60_000);
return useQuery<Map<string, PulseReactionState>>({
queryKey: pulseQueryKeys.reactions(noteIds),
queryFn: async () => {
@@ -100,7 +140,7 @@ export function usePulseReactionsQuery(
enabled: noteIds.length > 0,
staleTime: 15_000,
gcTime: 5 * 60_000,
refetchInterval: 60_000,
refetchInterval,
});
}
@@ -115,6 +155,8 @@ export function useNoteByIdQuery(noteId: string | null) {
}
export function useGlobalNotesQuery(enabled: boolean) {
const refetchInterval = useVisibleRefetchInterval(30_000);
return useQuery<UserNotesResponse>({
queryKey: pulseQueryKeys.globalNotes,
queryFn: async () =>
@@ -122,7 +164,7 @@ export function useGlobalNotesQuery(enabled: boolean) {
enabled,
staleTime: 15_000,
gcTime: 5 * 60_000,
refetchInterval: 30_000,
refetchInterval,
});
}
@@ -102,12 +102,19 @@ export function useSearchResults({
.slice(0, 5);
}, [channelLabels, channels, debouncedQuery]);
const hasSearchQuery = debouncedQuery.length >= MIN_SEARCH_QUERY_LENGTH;
const searchBackedQueriesEnabled = enabled && hasSearchQuery;
const userSearchQuery = useUserSearchQuery(debouncedQuery, {
enabled: enabled && debouncedQuery.length >= MIN_SEARCH_QUERY_LENGTH,
enabled: searchBackedQueriesEnabled,
limit,
});
const managedAgentsQuery = useManagedAgentsQuery({ enabled });
const relayAgentsQuery = useRelayAgentsQuery({ enabled });
const managedAgentsQuery = useManagedAgentsQuery({
enabled: searchBackedQueriesEnabled,
});
const relayAgentsQuery = useRelayAgentsQuery({
enabled: searchBackedQueriesEnabled,
});
const managedAgentPubkeys = React.useMemo(
() =>
new Set(
@@ -10,7 +10,7 @@ import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
import type { Channel, Workflow } from "@/shared/api/types";
import {
deleteWorkflow,
getChannelWorkflows,
getChannelsWorkflows,
triggerWorkflow,
} from "@/shared/api/tauriWorkflows";
import { Button } from "@/shared/ui/button";
@@ -83,15 +83,21 @@ export function WorkflowsView({
const allWorkflowsQuery = useQuery({
queryKey: allWorkflowsQueryKey(channelIdKey),
queryFn: async () => {
const results: WorkflowWithChannel[] = [];
await Promise.all(
memberChannels.map(async (channel) => {
const workflows = await getChannelWorkflows(channel.id);
for (const workflow of workflows) {
results.push({ workflow, channelName: channel.name });
}
}),
// Single batched relay query for all member channels, then group by the
// channel_id each workflow carries — replaces the per-channel fanout.
const channelNameById = new Map(
memberChannels.map((channel) => [channel.id, channel.name]),
);
const workflows = await getChannelsWorkflows(channelIds);
const results: WorkflowWithChannel[] = [];
for (const workflow of workflows) {
results.push({
workflow,
channelName: workflow.channelId
? (channelNameById.get(workflow.channelId) ?? "")
: "",
});
}
return results;
},
enabled: memberChannels.length > 0,
+20
View File
@@ -0,0 +1,20 @@
export async function collectWithConcurrency<T, R>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<R>,
): Promise<R[]> {
const workerCount = Math.min(Math.max(1, concurrency), items.length);
const results = new Array<R>(items.length);
let nextIndex = 0;
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const currentIndex = nextIndex++;
results[currentIndex] = await worker(items[currentIndex]);
}
}),
);
return results;
}
+8 -5
View File
@@ -29,6 +29,7 @@ import {
buildChannelMentionFilter,
buildGlobalStreamFilter,
} from "@/shared/api/relayChannelFilters";
import { collectWithConcurrency } from "@/shared/api/concurrency";
import { replayLiveSubscriptions } from "@/shared/api/relayReconnectReplay";
import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEmitter";
import {
@@ -41,7 +42,8 @@ import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
const RECONNECT_BASE_DELAY_MS = 1_000,
RECONNECT_MAX_DELAY_MS = 30_000,
EVENT_BATCH_MS = 16;
EVENT_BATCH_MS = 16,
AUX_BACKFILL_CONCURRENCY = 4;
/**
* Passive liveness check. The relay sends heartbeat pings every 30s; if no
@@ -217,10 +219,11 @@ export class RelayClient {
chunks.push(eventIds.slice(i, i + AUX_BACKFILL_CHUNK_SIZE));
}
const batches: RelayEvent[][] = [];
for (const ids of chunks) {
batches.push(await this.requestHistory(buildFilter(channelId, ids)));
}
const batches = await collectWithConcurrency(
chunks,
AUX_BACKFILL_CONCURRENCY,
(ids) => this.requestHistory(buildFilter(channelId, ids)),
);
return batches.flat();
}
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isRelayDependentQueryKey } from "./relayQueryInvalidation.ts";
test("relay invalidation includes relay-backed channel and profile queries", () => {
for (const queryKey of [
["channels"],
["channels", "channel-1", "members"],
["channel-messages", "channel-1"],
["thread-replies", "channel-1", "root-1"],
["forum-posts", "channel-1"],
["home-feed"],
["users-batch", "alice"],
["presence", "alice"],
["user-status", "alice"],
["relay-agents"],
["relayMembers"],
["archivedIdentities"],
["oaOwner", "alice"],
]) {
assert.equal(isRelayDependentQueryKey(queryKey), true, queryKey.join("/"));
}
});
test("relay invalidation includes social/workflow relay queries", () => {
for (const queryKey of [
["global-notes"],
["liked-notes", "alice"],
["pulse-reactions", "note-1"],
["workflows", "channel-1"],
["workflows-all", "channel-1"],
["workflow-runs", "workflow-1"],
["run-approvals", "workflow-1", "run-1"],
["reminders", "alice"],
["custom-emoji"],
]) {
assert.equal(isRelayDependentQueryKey(queryKey), true, queryKey.join("/"));
}
});
test("relay invalidation excludes local Tauri and disk-only query roots", () => {
for (const queryKey of [
["identity"],
["managed-agents"],
["personas"],
["teams"],
["acp-runtimes"],
["backend-providers"],
["managed-agent-log", "agent-1", 200],
["workspace-icon", "wss://relay.example"],
["agent-memory", "agent-1"],
]) {
assert.equal(isRelayDependentQueryKey(queryKey), false, queryKey.join("/"));
}
});
test("relay invalidation separates relay project queries from local repo work", () => {
for (const queryKey of [
["projects"],
["project", "project-1"],
["project", "project-1", "issues"],
["project", "project-1", "pull-requests"],
["projects", "issues", ["project-1"]],
["projects", "activity-summaries", ["addr-1"]],
]) {
assert.equal(isRelayDependentQueryKey(queryKey), true, queryKey.join("/"));
}
for (const queryKey of [
["project", "project-1", "repo-state"],
["project", "project-1", "repo-snapshot", "main"],
["project", "project-1", "repo-diff", "main"],
["project", "project-1", "local-repo-diff"],
["project", "project-1", "commit-diff", "remote"],
["projects", "local-repositories", "default"],
["projects", "repo-snapshots", "default", ["project-1"]],
]) {
assert.equal(isRelayDependentQueryKey(queryKey), false, queryKey.join("/"));
}
});
@@ -0,0 +1,84 @@
const RELAY_QUERY_ROOTS = new Set<string>([
"archivedIdentities",
"channel-canvas",
"channel-messages",
"channels",
"contact-list",
"custom-emoji",
"custom-emoji-own",
"forum-posts",
"forum-thread",
"global-notes",
"home-feed",
"liked-notes",
"my-notes",
"myRelayMembership",
"oaOwner",
"presence",
"profile",
"pulse-note",
"pulse-reactions",
"pulse-timeline",
"relay-agents",
"relayMembers",
"reminders",
"run-approvals",
"search-messages",
"thread-replies",
"user-profile",
"user-search",
"user-status",
"users-batch",
"workflow",
"workflow-runs",
"workflows",
"workflows-all",
]);
const RELAY_PROJECT_QUERY_PARTS = new Set<string>([
"activity-summaries",
"issues",
"pull-requests",
]);
const LOCAL_PROJECT_QUERY_PARTS = new Set<string>([
"commit-diff",
"local-repo-diff",
"local-repo-snapshot",
"local-repositories",
"repo-diff",
"repo-snapshot",
"repo-state",
"repo-sync-status",
]);
function isRelayDependentProjectQueryKey(queryKey: readonly unknown[]) {
if (queryKey[0] === "projects") {
const scope = queryKey[1];
if (scope === undefined) return true;
if (typeof scope !== "string") return false;
if (LOCAL_PROJECT_QUERY_PARTS.has(scope)) return false;
return RELAY_PROJECT_QUERY_PARTS.has(scope);
}
if (queryKey[0] === "project") {
const scope = queryKey[2];
if (scope === undefined) return true;
if (typeof scope !== "string") return false;
if (LOCAL_PROJECT_QUERY_PARTS.has(scope)) return false;
return RELAY_PROJECT_QUERY_PARTS.has(scope);
}
return false;
}
export function isRelayDependentQueryKey(queryKey: readonly unknown[]) {
const root = queryKey[0];
if (typeof root !== "string") return false;
if (RELAY_QUERY_ROOTS.has(root)) return true;
return isRelayDependentProjectQueryKey(queryKey);
}
export function isRelayDependentQuery(query: { queryKey: readonly unknown[] }) {
return isRelayDependentQueryKey(query.queryKey);
}
@@ -172,3 +172,81 @@ test("channel reconnect replay pages the missed window until a short page", asyn
]);
assert.equal(delivered.length, 1008);
});
test("reconnect replay starts live REQs in parallel and preserves per-sub page order", async () => {
const sentPayloads = [];
const sendResolvers = [];
const historyFiltersByChannel = {
"channel-1": [],
"channel-2": [],
};
const pagesByChannel = {
"channel-1": [
eventRange("c1-full", 1501, 500),
eventRange("c1-short", 1490, 2),
],
"channel-2": [
eventRange("c2-full", 1701, 500),
eventRange("c2-short", 1690, 2),
],
};
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: buildChannelFilter("channel-1", 50),
onEvent: () => {},
lastSeenCreatedAt: 1000,
},
],
[
"live-2",
{
mode: "live",
filter: buildChannelFilter("channel-2", 50),
onEvent: () => {},
lastSeenCreatedAt: 1000,
},
],
]);
const replayPromise = replayLiveSubscriptions({
subscriptions,
now: 2000,
pageReplayConcurrency: 2,
sendRaw: (payload) => {
sentPayloads.push(payload);
return new Promise((resolve) => {
sendResolvers.push(resolve);
});
},
requestHistory: async (filter) => {
const channelId = filter["#h"]?.[0];
historyFiltersByChannel[channelId].push(filter.until);
return pagesByChannel[channelId].shift() ?? [];
},
});
await Promise.resolve();
assert.deepEqual(
sentPayloads.map((payload) => payload[1]),
["live-1", "live-2"],
);
assert.equal(sendResolvers.length, 2);
assert.deepEqual(historyFiltersByChannel, {
"channel-1": [],
"channel-2": [],
});
for (const resolve of sendResolvers) {
resolve();
}
await replayPromise;
assert.deepEqual(historyFiltersByChannel, {
"channel-1": [2000, 1501],
"channel-2": [2000, 1701],
});
});
+67 -22
View File
@@ -7,6 +7,25 @@ import type { RelayEvent } from "@/shared/api/types";
const RECONNECT_REPLAY_SKEW_SECS = 5;
export const RECONNECT_REPLAY_PAGE_LIMIT = 500;
export const RECONNECT_REPLAY_PAGE_CONCURRENCY = 4;
async function runWithConcurrency<T>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<void>,
) {
const workerCount = Math.min(Math.max(1, concurrency), items.length);
let nextIndex = 0;
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const item = items[nextIndex++];
await worker(item);
}
}),
);
}
export function buildReconnectReplayFilter(
filter: RelaySubscriptionFilter,
@@ -84,34 +103,60 @@ export async function replayLiveSubscriptions({
sendRaw,
requestHistory,
now = Math.floor(Date.now() / 1_000),
pageReplayConcurrency = RECONNECT_REPLAY_PAGE_CONCURRENCY,
}: {
subscriptions: Map<string, RelaySubscription>;
sendRaw: (payload: unknown[]) => Promise<void>;
requestHistory: (filter: RelaySubscriptionFilter) => Promise<RelayEvent[]>;
now?: number;
pageReplayConcurrency?: number;
}) {
for (const [subId, subscription] of subscriptions) {
if (subscription.mode !== "live") continue;
const replayRequests = Array.from(subscriptions.entries())
.filter(
(
entry,
): entry is [string, Extract<RelaySubscription, { mode: "live" }>] =>
entry[1].mode === "live",
)
.map(([subId, subscription]) => {
const replaySince =
subscription.lastSeenCreatedAt === undefined
? undefined
: Math.max(
0,
subscription.lastSeenCreatedAt - RECONNECT_REPLAY_SKEW_SECS,
);
const shouldPageReplay =
replaySince !== undefined &&
shouldPageReconnectReplay(subscription.filter);
const replaySince =
subscription.lastSeenCreatedAt === undefined
? undefined
: Math.max(
0,
subscription.lastSeenCreatedAt - RECONNECT_REPLAY_SKEW_SECS,
);
const shouldPageReplay =
replaySince !== undefined &&
shouldPageReconnectReplay(subscription.filter);
await sendRaw([
"REQ",
subId,
shouldPageReplay
? subscription.filter
: buildReconnectReplayFilter(subscription.filter, replaySince),
]);
return { subId, subscription, replaySince, shouldPageReplay };
});
if (shouldPageReplay) {
await Promise.all(
replayRequests.map(
({ subId, subscription, replaySince, shouldPageReplay }) =>
sendRaw([
"REQ",
subId,
shouldPageReplay
? subscription.filter
: buildReconnectReplayFilter(subscription.filter, replaySince),
]),
),
);
await runWithConcurrency(
replayRequests.filter(
(
request,
): request is typeof request & {
replaySince: number;
shouldPageReplay: true;
} => request.shouldPageReplay && request.replaySince !== undefined,
),
pageReplayConcurrency,
async ({ subId, subscription, replaySince }) => {
await replayReconnectHistoryPages({
subscription,
since: replaySince,
@@ -119,6 +164,6 @@ export async function replayLiveSubscriptions({
isActive: () => subscriptions.get(subId) === subscription,
requestHistory,
});
}
}
},
);
}
+16
View File
@@ -169,6 +169,22 @@ export async function getChannelWorkflows(
return raw.map(fromRawWorkflow);
}
/**
* Fetch workflows across many channels in a single relay round-trip.
*
* Replaces the per-channel `Promise.all(getChannelWorkflows)` fanout on the
* Workflows overview: the backend `#h` filter matches any listed channel, and
* each returned workflow carries its own `channelId` so callers can group.
*/
export async function getChannelsWorkflows(
channelIds: string[],
): Promise<Workflow[]> {
const raw = await invokeTauri<RawWorkflow[]>("get_channels_workflows", {
channelIds,
});
return raw.map(fromRawWorkflow);
}
export async function getWorkflow(workflowId: string): Promise<Workflow> {
const raw = await invokeTauri<RawWorkflow>("get_workflow", { workflowId });
return fromRawWorkflow(raw);
+9 -6
View File
@@ -16,6 +16,7 @@ import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner";
import { relayClient } from "@/shared/api/relayClient";
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
import { relayReconnectController } from "@/shared/api/relayReconnectController";
function buildDeps(onSuccess: () => void, onBackstop: () => void) {
@@ -59,12 +60,14 @@ export function useReconnectRelay(): {
onSuccessRef.current = React.useCallback(() => {
// Defer query invalidation so callers render the recovered state first.
window.setTimeout(() => {
void queryClient.invalidateQueries().catch((err) => {
console.error(
"[useReconnectRelay] failed to refresh queries after reconnect:",
err,
);
});
void queryClient
.invalidateQueries({ predicate: isRelayDependentQuery })
.catch((err) => {
console.error(
"[useReconnectRelay] failed to refresh queries after reconnect:",
err,
);
});
}, 0);
}, [queryClient]);
+9 -5
View File
@@ -3,6 +3,7 @@ import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { ConnectionState } from "@/shared/api/relayClientShared";
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
import {
isRelayConnectionDegraded,
useRelayConnection,
@@ -82,12 +83,12 @@ export class RelayAutoHealScheduler {
/**
* Auto-heal: when the connection recovers from a degraded state, invalidate
* all queries so errored queries (e.g. messages, which don't poll) refetch
* automatically without requiring a manual reconnect action.
* relay-dependent queries so errored queries (e.g. messages, which don't poll)
* refetch automatically without requiring a manual reconnect action.
*
* Rate-limited to prevent a flappy connection (e.g. VPN toggling) from
* firing an unfiltered invalidation — ~20-40 requests across active queries
* with retry:1 — every time the relay briefly recovers.
* firing a relay-wide invalidation across active queries with retry:1 every
* time the relay briefly recovers.
*
* When a recovery is suppressed by the rate limiter (an earlier flap consumed
* the budget), a deferred heal is scheduled for the remaining window so the
@@ -101,7 +102,10 @@ export function useRelayAutoHeal(): void {
if (schedulerRef.current === null) {
schedulerRef.current = new RelayAutoHealScheduler(
() => void queryClient.invalidateQueries(),
() =>
void queryClient.invalidateQueries({
predicate: isRelayDependentQuery,
}),
AUTO_HEAL_MIN_INTERVAL_MS,
window.setTimeout.bind(window),
window.clearTimeout.bind(window),
@@ -4,11 +4,15 @@ import type { Channel } from "@/shared/api/types";
type ChannelNavigationContextValue = {
channels: Channel[];
/** Names of non-DM channels, memoised once so render-hot consumers (message
* rows) don't each re-filter the full channel list on every render. */
nonDmChannelNames: string[];
};
const ChannelNavigationContext =
React.createContext<ChannelNavigationContextValue>({
channels: [],
nonDmChannelNames: [],
});
export function ChannelNavigationProvider({
@@ -18,7 +22,15 @@ export function ChannelNavigationProvider({
channels: Channel[];
children: React.ReactNode;
}) {
const value = React.useMemo(() => ({ channels }), [channels]);
const value = React.useMemo(
() => ({
channels,
nonDmChannelNames: channels
.filter((c) => c.channelType !== "dm")
.map((c) => c.name),
}),
[channels],
);
return (
<ChannelNavigationContext.Provider value={value}>
+11
View File
@@ -2372,6 +2372,13 @@ function handleGetChannelWorkflows(args: { channelId: string }) {
return mockWorkflows.filter((w) => w.channel_id === args.channelId);
}
function handleGetChannelsWorkflows(args: { channelIds: string[] }) {
const ids = new Set(args.channelIds);
return mockWorkflows.filter(
(w) => w.channel_id != null && ids.has(w.channel_id),
);
}
function handleGetWorkflow(args: { workflowId: string }) {
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
@@ -8810,6 +8817,10 @@ export function maybeInstallE2eTauriMocks() {
return handleGetChannelWorkflows(
payload as Parameters<typeof handleGetChannelWorkflows>[0],
);
case "get_channels_workflows":
return handleGetChannelsWorkflows(
payload as Parameters<typeof handleGetChannelsWorkflows>[0],
);
case "get_workflow":
return handleGetWorkflow(
payload as Parameters<typeof handleGetWorkflow>[0],